From 106e5ce0bc199547e1e93a866bcc0cd7d2df35e7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 25 Aug 2026 12:00:24 +0800 Subject: [PATCH 001/130] feat(bundle): default session telemetry to feedback-gated sharing --- ...2026-08-10-telemetry-default-off.i18n.yaml | 4 +-- .../2026-08-10-telemetry-default-off.md | 2 +- .../2026-08-10-telemetry-default-off.zh.md | 2 +- ...feedback-gated-telemetry-default.i18n.yaml | 6 ++++ ...-08-25-feedback-gated-telemetry-default.md | 29 +++++++++++++++++++ ...-25-feedback-gated-telemetry-default.zh.md | 29 +++++++++++++++++++ apps/cli/reference/README.i18n.yaml | 4 +-- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- packages/bundle/base/cordis.patch.yml | 14 +++++---- packages/bundle/base/tests/base.spec.ts | 2 +- 11 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md create mode 100644 .agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 1132da788f..59cab0c69c 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: 3f56817c9c23ec55f2173b66fa915ab05646b2a7 -2026-08-10-telemetry-default-off.zh.md: ea89ea94dfea3106886e555e172ad8a6ab39305b +2026-08-10-telemetry-default-off.md: db55eda83628dd2908b312000457c75e0bc07c8f +2026-08-10-telemetry-default-off.zh.md: e444f4aad2782eb46c4b787f5fd14dd84b67003e diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index 3f56817c9c..db55eda836 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -10,7 +10,7 @@ DeepSeek Harness has two outbound telemetry feeds. During internal testing, the ## Decision -Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. +Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and empty values resolve to `DISABLED`. `@deepseek-ai/dsh-session-telemetry-otel` also resolves an omitted `mode` to `DISABLED`, which constructs no OTel provider, processor, or exporter and leaves feedback in the local session log. The shared dsh base keeps the backend row mounted so disabled feedback can still explain that nothing was shared. A deployment opts into Session Log sharing through `FULL` or `FEEDBACK_ONLY`; only `FULL` also permits dsh-sdk launcher reporting. The shared base's session-backend default was later superseded by the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md), which resolves an unset `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY`; the hard opt-out and the launcher rule below remain current. Any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative pre-load hard opt-out. The [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index ea89ea94df..e444f4aad2 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -10,7 +10,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 ## 决策 -两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。 +两路数据流都使用 `DSH_TELEMETRY_MODE` 作为正向授权配置。未设置和空值都解析为 `DISABLED`。`@deepseek-ai/dsh-session-telemetry-otel` 也将省略的 `mode` 解析为 `DISABLED`;该模式不构造 OTel 提供方、处理器或导出器,并将反馈留在本地会话日志中。dsh 共享基础配置继续挂载后端配置行,使禁用模式仍可在记录反馈时说明没有共享任何内容。部署方通过 `FULL` 或 `FEEDBACK_ONLY` 显式启用 Session Log 共享;只有 `FULL` 还允许 dsh-sdk 启动器上报。共享基础配置中会话后端的默认值后来被[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)取代,未设置的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY`;硬性退出开关和下文的启动器规则仍然有效。任何非空 `DSH_TELEMETRY_DISABLED` 仍是具有最高优先级的加载前硬性退出开关。[默认挂载决策](2026-07-31-web-telemetry-default-mount.zh.md)继续负责 endpoint、批处理节奏和退出排空设置。 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.zh.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)删除之前,仅取代了启动器默认允许上报的规则。 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml new file mode 100644 index 0000000000..9a7b9066ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.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/feature/2026-08-25-feedback-gated-telemetry-default.md +2026-08-25-feedback-gated-telemetry-default.md: 1a3766ee44907cee330a5b381aad3a91c7efc58f +2026-08-25-feedback-gated-telemetry-default.zh.md: 677574fdb51a38f36db7cc45ba479a36b5bdd629 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md new file mode 100644 index 0000000000..1a3766ee44 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md @@ -0,0 +1,29 @@ +# Agent Note: Feedback-gated session-telemetry default + +Status: implemented + +English | [中文](2026-08-25-feedback-gated-telemetry-default.zh.md) + +## Problem + +Diagnosing a `/feedback` report needs the session data the report describes. With the shared base resolving an unset `DSH_TELEMETRY_MODE` to `DISABLED`, a default installation's feedback reached its receiver with no session data at all, and the reporter had no way to grant access at the moment they asked for help; only deployments that had exported `DSH_TELEMETRY_MODE` beforehand ever delivered a diagnosable report. + +## Decision + +The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; recording feedback releases the canonical session-log prefix through that exact event to the configured OTLP endpoint, and the acknowledgement's sharing disclosure states that recording feedback releases the session prefix. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. + +This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. + +## Alternatives considered + +**Keep `DISABLED` and instruct reporters to re-run with `DSH_TELEMETRY_MODE=FEEDBACK_ONLY`.** Rejected: the session that exhibited the problem is the one worth uploading, and re-running loses it. + +**Default to `FULL`.** Rejected: continuous export without any user action is exactly what the default-off decision forbids, and nothing in a fresh installation authorizes it. + +**Gate the official DeepSeek `dsh_session_log` request contribution on feedback instead of reviving the OTel default.** Not taken here: that contribution uploads through subsequent LLM requests rather than at the feedback boundary, so a session's final feedback would never be delivered; a feedback-triggered flush on that path is a larger design than a default flip. + +## Consequences + +- A fresh installation uploads the session-log prefix to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. +- Released exports remain the raw captured copy: the shipped base mounts no `session-telemetry/record` redaction rule, so they can contain message text, tool arguments and results, and workspace paths. +- The sharing disclosure is part of the `/feedback` acknowledgement, so the user reads it after the release has been triggered. A deployment that requires prior informed consent must override the default to `DISABLED` or add a pre-upload confirmation before this default is defensible there. diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md new file mode 100644 index 0000000000..677574fdb5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 反馈门控的会话遥测默认值 + +Status: implemented + +[English](2026-08-25-feedback-gated-telemetry-default.md) | 中文 + +## 问题 + +诊断一条 `/feedback` 报告需要报告所描述的会话数据。共享基础配置把未设置的 `DSH_TELEMETRY_MODE` 解析为 `DISABLED`,因此默认安装发出的反馈到达接收方时不带任何会话数据,报告者在求助的那一刻也没有授权共享的途径;只有事先导出了 `DSH_TELEMETRY_MODE` 的部署才能交付可诊断的报告。 + +## 决定 + +共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;记录反馈时通过该事件把权威会话日志前缀释放到已配置的 OTLP 端点,确认信息中的共享声明会说明记录反馈将释放会话前缀。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 + +本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。 + +## 考虑过的替代方案 + +**保持 `DISABLED`,让报告者带着 `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 重跑。** 否决:值得上传的正是出现问题的那个会话,重跑会丢掉它。 + +**默认 `FULL`。** 否决:没有任何用户动作的持续导出正是默认关闭决定所禁止的,全新安装中没有任何东西授权它。 + +**改为在反馈时门控官方 DeepSeek `dsh_session_log` 请求贡献,而不是恢复 OTel 默认值。** 此处未采用:该贡献通过后续 LLM 请求上传,而不是在反馈边界上传,会话的最后一条反馈永远不会被交付;在那条路径上做反馈触发的冲刷是比翻转默认值更大的设计。 + +## 后果 + +- 全新安装只在用户记录 `/feedback` 时把会话日志前缀上传到生产 collector;没有其他触发上传的途径。 +- 释放的导出仍是未加工的原始副本:随附基础配置没有挂载 `session-telemetry/record` 脱敏规则,导出可能包含消息文本、工具参数和结果,以及 workspace 路径。 +- 共享声明是 `/feedback` 确认信息的一部分,用户读到它时释放已被触发。要求事先知情同意的部署必须把默认值覆盖为 `DISABLED`,或在上传前增加确认步骤,此默认值在那类部署中才站得住。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 96e7b4c7b0..9e858e257b 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac -README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481 +README.md: e2814bbda5249fbf0ca0fb9c7f2e98175c5d4e0c +README.zh.md: e714e08147f5430871c17023e67563eefa85118f diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 33de399dc4..e2814bbda5 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -91,7 +91,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. -Session telemetry stays local by default. `DSH_TELEMETRY_MODE=FULL` streams every projected session event as OTLP/HTTP logs, while `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` uploads a session-log suffix only when feedback is recorded. `DSH_TELEMETRY_OTLP_URL` selects another collector, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. The shipped base has no telemetry redaction rule, so explicitly enabled exports can contain message text, tool arguments and results, and workspace paths; the [default-off Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md) owns that deployment decision. +Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and recording feedback releases the session-log prefix through that event. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 0f20245f31..e714e08147 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -91,7 +91,7 @@ dsh web --help 基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 -会话遥测默认留在本地。`DSH_TELEMETRY_MODE=FULL` 将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 则仅在记录反馈时上传会话日志后缀。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空的 `DSH_TELEMETRY_DISABLED` 都是具有最终效力的遥测强制关闭开关。随附基础配置没有遥测脱敏规则,因此显式启用的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[默认关闭 Agent Note](../../../.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md)。 +会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,记录反馈时通过该事件释放会话日志前缀。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 981e791fb4..33f8b693e2 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -138,11 +138,13 @@ - id: session-projection name: '@deepseek-ai/dsh-session-projection' - # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE - # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors - # session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction - # rule, so exports are the raw captured copy. The deployment stance, env - # seams, and follow-ups are pinned in the default-off Agent Note. + # Session telemetry defaults to feedback-gated sharing: FEEDBACK_ONLY + # uploads the canonical session-log prefix only after the user records + # /feedback. DSH_TELEMETRY_MODE overrides to FULL or DISABLED; uploading + # mirrors session-log records onto OTLP/HTTP logs with no session-telemetry/record + # redaction rule, so exports are the raw captured copy. The deployment + # stance, env seams, and follow-ups are pinned in the feedback-gated-default + # Agent Note. # DSH_TELEMETRY_OTLP_URL overrides the production endpoint. A non-empty # DSH_TELEMETRY_DISABLED — any value, including '0'/'false' — opts the # process out (the launchers patch the row disabled; config cannot disable @@ -160,7 +162,7 @@ - id: session-telemetry-otel name: '@deepseek-ai/dsh-session-telemetry-otel' config: - mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED' + mode: !!js process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY' shutdownTimeoutMillis: 3000 exporter: url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://harness-telemetry.deepseeksvc.com/v1/logs' diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 4fc16ead7c..df3882968b 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -33,7 +33,7 @@ describe('dsh-base bundle', () => { expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({ - __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", + __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'FEEDBACK_ONLY'", }) expect(rows.find(row => row.id === 'hmr')).toMatchObject({ disabled: true, From ac4a2f979272ecf6bb51d546a9d4bb2b0a59a129 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 10:05:31 +0800 Subject: [PATCH 002/130] =?UTF-8?q?fix(feedback):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20accurate=20release=20wording,=20current-state=20not?= =?UTF-8?q?es,=20default-mode=20snapshot=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...7-31-web-telemetry-default-mount.i18n.yaml | 4 +- .../2026-07-31-web-telemetry-default-mount.md | 8 +- ...26-07-31-web-telemetry-default-mount.zh.md | 8 +- ...2026-08-10-telemetry-default-off.i18n.yaml | 4 +- .../2026-08-10-telemetry-default-off.md | 4 +- .../2026-08-10-telemetry-default-off.zh.md | 4 +- ...feedback-gated-telemetry-default.i18n.yaml | 4 +- ...-08-25-feedback-gated-telemetry-default.md | 4 +- ...-25-feedback-gated-telemetry-default.zh.md | 4 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 4 +- apps/cli/reference/README.zh.md | 4 +- apps/web/tests/feedback-release.e2e.ts | 137 ++++++++++++++++++ apps/web/tests/scaffold.ts | 12 +- apps/web/tsconfig.json | 1 + packages/bundle/base/cordis.patch.yml | 6 +- .../command-feedback/README.i18n.yaml | 4 +- packages/feedback/command-feedback/README.md | 2 +- .../feedback/command-feedback/README.zh.md | 2 +- .../feedback/command-feedback/src/index.ts | 2 +- .../tests/command-feedback.spec.ts | 2 +- .../web/feedback-release/ack.expected.md | 46 ++++++ snapshots/web/feedback-release/snapshot.yml | 9 ++ tsconfig.host.json | 1 + 24 files changed, 239 insertions(+), 41 deletions(-) create mode 100644 apps/web/tests/feedback-release.e2e.ts create mode 100644 snapshots/web/feedback-release/ack.expected.md create mode 100644 snapshots/web/feedback-release/snapshot.yml diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml index 2d37aaba9f..dd8a8cdb84 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.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-31-web-telemetry-default-mount.md -2026-07-31-web-telemetry-default-mount.md: a492356eccba9f272ee216777eb518750c7b6b62 -2026-07-31-web-telemetry-default-mount.zh.md: 3d852229c069ae32f328c8dae38cfdf1744c7293 +2026-07-31-web-telemetry-default-mount.md: aea90ef928afaa7669df8046fa98f16c575618f0 +2026-07-31-web-telemetry-default-mount.zh.md: 122b7ad593c18cd547ade835827e8903d34da441 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md index a492356ecc..aea90ef928 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md @@ -10,12 +10,12 @@ The telemetry seam and OTel backend ([revival Note](2026-07-23-session-telemetry ## Decision -The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) keeps the mounted row in `DISABLED` mode unless a deployment explicitly selects `FULL` or `FEEDBACK_ONLY`; the endpoint alone does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. +The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the `session-telemetry-otel` row with a baked-in production endpoint, so every base-backed profile has one consistent telemetry capability. The standalone [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.md) deliberately omits that row. The [default-off decision](2026-08-10-telemetry-default-off.md) originally kept the mounted row in `DISABLED` mode; the [feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md) now resolves an unset mode to `FEEDBACK_ONLY`, uploading only when the user records `/feedback`. The endpoint alone still does not authorize reporting. Web and headless use the [bounded, escalating process-shutdown controller](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.md) on SIGINT/SIGTERM, giving an enabled backend's three-second shutdown deadline time to drain before the five-second launcher bound. | Ruling | Value | Rationale | |---|---|---| | Mount surface | `packages/bundle/base/cordis.patch.yml` | One capability row for every profile that loads the shared base | -| Sharing mode | `DSH_TELEMETRY_MODE`, default `DISABLED`; explicit `FULL` or `FEEDBACK_ONLY` opts in | A fresh profile makes no telemetry network request, while internal deployments retain both upload policies | +| Sharing mode | `DSH_TELEMETRY_MODE`, default `FEEDBACK_ONLY` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)); explicit `FULL` or `DISABLED` overrides | A fresh profile uploads only when the user records `/feedback`, while internal deployments retain both explicit policies | | Endpoint | `DSH_TELEMETRY_OTLP_URL`, default `https://harness-telemetry.deepseeksvc.com/v1/logs` | Internal collector; the env override serves local/dev runs | | Hard opt-out | any non-empty `DSH_TELEMETRY_DISABLED` (including `0`/`false`) disables the row | The launcher patch takes effect before load-time transport validation and overrides every configured mode | | Cadence | `processor.scheduledDelayMillis: 10000` (10s/batch) in uploading modes | Streaming while the session runs, never exit-time-only; a crash loses at most the last unexported interval | @@ -23,7 +23,7 @@ The shared dsh base bundle (`packages/bundle/base/cordis.patch.yml`) mounts the | Compression | `compression: gzip` | Event bodies carry full content; cross-datacenter bandwidth | | CI isolation | top-level `env: DSH_TELEMETRY_DISABLED: '1'` in GitHub workflows | Defense in depth keeps test sessions local even when a job explicitly selects an uploading mode | -The base bundle test pins the shipped `DISABLED` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. +The base bundle test pins the shipped `FEEDBACK_ONLY` mode expression, the backend suite pins that omitted mode constructs no transport, and the real Loader composition suite explicitly selects each uploading mode when it verifies OTLP delivery. ## Alternatives considered @@ -35,6 +35,6 @@ The base bundle test pins the shipped `DISABLED` mode expression, the backend su ## Consequences -- A developer running `dsh web` without telemetry configuration makes no telemetry network request. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. +- A developer running `dsh web` without telemetry configuration makes no telemetry network request until they record `/feedback`. An internal deployment sets `DSH_TELEMETRY_MODE` and may point `DSH_TELEMETRY_OTLP_URL` at another collector. - **No redaction rule is mounted**: explicitly enabled exports are the raw captured copy (full user/assistant message text, tool arguments and results, the system prompt, the local `session.cwd` path). Crossing a trust boundary requires `session-telemetry/record` rules first — the redaction rule, remaining identity Resource attributes, and usage metrics remain separate deployment work. The anonymous user id ships through the [anonymous-user-id Note](2026-07-31-telemetry-anonymous-user-id.md). - Test rigs remain local by default; explicit uploading-mode tests provide their own collector and mode. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md index 3d852229c0..122b7ad593 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.zh.md @@ -10,12 +10,12 @@ Status: implemented ## 决策 -共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)让已挂载配置项保持 `DISABLED` 模式,除非部署方显式选择 `FULL` 或 `FEEDBACK_ONLY`;仅配置 endpoint 不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 +共享 dsh 基础组合包(`packages/bundle/base/cordis.patch.yml`)挂载带有内置生产 endpoint 的 `session-telemetry-otel` 配置行,使每个基于 base 的 profile 都具有一致的遥测能力。独立的 [`sdk-minimal` profile](../architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md)刻意省略该配置项。[默认关闭决策](2026-08-10-telemetry-default-off.zh.md)最初让已挂载配置项保持 `DISABLED` 模式;[反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)现在把未设置的模式解析为 `FEEDBACK_ONLY`,只在用户记录 `/feedback` 时上传。仅配置 endpoint 仍不构成上报授权。Web 与 headless 在 SIGINT/SIGTERM 时使用[有界、可升级的进程关闭控制器](../bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md),在启动器 5 秒上限到期前,先给已启用的后端 3 秒关闭截止时间完成排空。 | 决策项 | 取值 | 理由 | |---|---|---| | 挂载面 | `packages/bundle/base/cordis.patch.yml` | 每个加载共享基础组合包的 profile 都使用同一个能力配置行 | -| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `DISABLED`;显式设置 `FULL` 或 `FEEDBACK_ONLY` 即启用 | 新 profile 不发出遥测网络请求,内部部署仍可使用两种上传策略 | +| 共享模式 | `DSH_TELEMETRY_MODE`,默认 `FEEDBACK_ONLY`([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md));显式设置 `FULL` 或 `DISABLED` 即覆盖 | 新 profile 只在用户记录 `/feedback` 时上传,内部部署仍可使用两种显式策略 | | endpoint | `DSH_TELEMETRY_OTLP_URL`,缺省 `https://harness-telemetry.deepseeksvc.com/v1/logs` | 内部 collector;env 覆盖供本地/联调 | | 硬性退出 | `DSH_TELEMETRY_DISABLED` 非空(含 `0`/`false`)即禁用该配置行 | 启动器 patch 在加载期传输校验之前生效,并覆盖所有已配置模式 | | 上报节奏 | 上传模式中为 `processor.scheduledDelayMillis: 10000`(10s/批) | 在会话运行期间流式上报,而非仅在退出时上报;崩溃至多丢失最后一个尚未导出间隔内的数据 | @@ -24,7 +24,7 @@ Status: implemented | CI 隔离 | GitHub 工作流顶层 `env: DSH_TELEMETRY_DISABLED: '1'` | 即使 CI 任务显式选择上传模式,纵深防御也会让测试会话留在本地 | -基础组合包测试固定交付的 `DISABLED` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 +基础组合包测试固定交付的 `FEEDBACK_ONLY` 模式表达式,后端测试套件固定省略模式时不构造传输,真实 Loader 组合测试则在验证 OTLP 投递时显式选择每种上传模式。 ## 考虑过的替代方案 @@ -36,6 +36,6 @@ Status: implemented ## 后果 -- 开发者运行没有遥测配置的 `dsh web` 时,不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 +- 开发者运行没有遥测配置的 `dsh web` 时,在记录 `/feedback` 之前不会发出遥测网络请求。内部部署需设置 `DSH_TELEMETRY_MODE`,并可让 `DSH_TELEMETRY_OTLP_URL` 指向其他 collector。 - **没有挂载任何脱敏规则**:显式启用的导出即原始捕获副本(用户/助手消息全文、工具参数与工具结果、系统提示词、`session.cwd` 本地路径)。跨信任边界前必须先挂载 `session-telemetry/record` 规则;脱敏规则、其余身份 Resource 属性和使用情况指标仍是独立的部署工作。匿名 user id 由[匿名 user id Note](2026-07-31-telemetry-anonymous-user-id.zh.md)交付。 - 测试载具默认将数据留在本地;显式启用上传模式的测试提供自己的 collector 和模式。 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml index 59cab0c69c..823b1327b0 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.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-10-telemetry-default-off.md -2026-08-10-telemetry-default-off.md: db55eda83628dd2908b312000457c75e0bc07c8f -2026-08-10-telemetry-default-off.zh.md: e444f4aad2782eb46c4b787f5fd14dd84b67003e +2026-08-10-telemetry-default-off.md: 1bc9719f7036509f1fff8c28f0607669505280ad +2026-08-10-telemetry-default-off.zh.md: 8f8bbb57810125059c177dc1c94f7631040a1d58 diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md index db55eda836..1bc9719f70 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.md @@ -14,7 +14,7 @@ Both feeds use `DSH_TELEMETRY_MODE` as their positive consent setting. Unset and The dsh-sdk launcher reads the same variable without parsing `cordis.yml` or booting Cordis. `FULL` permits reporting; `FEEDBACK_ONLY`, `DISABLED`, unset, and empty values deny it. Consent is frozen from the launching environment before the command runs, because `dsh-sdk start` loads a project `.env` and project code can mutate `process.env`: resolving afterwards would let a project grant reporting of its own configuration, which the [configuration source ownership decision](../architecture/2026-08-04-configuration-source-ownership.md) denies for the whole `DSH_*` namespace. An unsupported mode denies rather than throwing at that boundary, since telemetry may never change a command's result. This rule superseded the default-on launcher consent before the launcher and its proposal were deleted by the [SDK project toolchain removal](../simplification/2026-08-11-remove-sdk-project-toolchain.md). -The [CLI reference README](../../../../apps/cli/reference/README.md) documents the deployment stance: Session Log upload is off by default, `DSH_TELEMETRY_MODE=FEEDBACK_ONLY` and `DSH_TELEMETRY_MODE=FULL` are the two opt-in choices, and explicitly enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload. +The [CLI reference README](../../../../apps/cli/reference/README.md) documents the current deployment stance: the shared base defaults to feedback-gated sharing ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)), `DSH_TELEMETRY_MODE=FULL` and `DSH_TELEMETRY_MODE=DISABLED` are the explicit overrides, and enabled exports can contain complete session content. The restored [testing-stage onboarding notice](2026-08-13-shared-modal-product-onboarding.md) contains no telemetry copy, so the product still presents no prompt about enabling upload. ## Alternatives considered @@ -28,4 +28,4 @@ The [CLI reference README](../../../../apps/cli/reference/README.md) documents t ## Consequences -Fresh profiles and projects make no telemetry network request. Internal deployments select one mode for both feeds: `FEEDBACK_ONLY` permits only feedback-triggered Session Log sharing, while `FULL` also enables launcher reporting. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. +Fresh profiles and projects make no telemetry network request until the user records `/feedback` ([feedback-gated default](2026-08-25-feedback-gated-telemetry-default.md)). `FULL` still requires an explicit setting; the launcher feed it once also enabled was deleted with the SDK project toolchain. The existing hard opt-out remains effective, and uploading modes retain their endpoint validation, redaction responsibility, batching, and shutdown behavior. diff --git a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md index e444f4aad2..8f8bbb5781 100644 --- a/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-telemetry-default-off.zh.md @@ -14,7 +14,7 @@ DeepSeek Harness 有两路出站遥测数据流。在内测阶段,共享基础 dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cordis。`FULL` 允许上报;`FEEDBACK_ONLY`、`DISABLED`、未设置和空值都会拒绝。授权在命令执行前从启动环境冻结:`dsh-sdk start` 会加载项目 `.env`,项目代码也能修改 `process.env`,若在执行后解析,项目便能自行授权上报其自身配置,而[配置来源所有权决策](../architecture/2026-08-04-configuration-source-ownership.zh.md)对整个 `DSH_*` 命名空间禁止这种行为。在该边界上,不受支持的模式按拒绝处理而非抛出,因为遥测不得改变命令结果。此规则在启动器及其提案被[SDK 项目工具链移除决策](../simplification/2026-08-11-remove-sdk-project-toolchain.zh.md)删除之前,仅取代了启动器默认允许上报的规则。 -[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了这一部署口径:会话日志上传默认关闭,`DSH_TELEMETRY_MODE=FEEDBACK_ONLY` 和 `DSH_TELEMETRY_MODE=FULL` 是两种显式启用选项,显式开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。 +[CLI reference README](../../../../apps/cli/reference/README.zh.md) 记录了当前的部署口径:共享基础配置默认按反馈门控共享([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md)),`DSH_TELEMETRY_MODE=FULL` 和 `DSH_TELEMETRY_MODE=DISABLED` 是显式覆盖值,开启后的导出可能包含完整会话内容。恢复后的[测试阶段引导声明](2026-08-13-shared-modal-product-onboarding.zh.md)不包含遥测文案,因此产品仍不提供任何关于开启上传的提示。 ## 考虑过的替代方案 @@ -28,4 +28,4 @@ dsh-sdk 启动器读取同一变量,不解析 `cordis.yml`,也不启动 Cord ## 后果 -全新 profile 和项目不发出任何遥测网络请求。内部部署为两路数据流选择一个模式:`FEEDBACK_ONLY` 只允许由反馈触发的 Session Log 共享,`FULL` 还会启用启动器上报。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 +全新 profile 和项目在用户记录 `/feedback` 之前不发出任何遥测网络请求([反馈门控默认值决定](2026-08-25-feedback-gated-telemetry-default.zh.md))。`FULL` 仍需显式设置;它曾一并启用的启动器数据流已随 SDK 项目工具链删除。现有硬性退出继续生效,上传模式也保留 endpoint 校验、脱敏责任、批处理和关闭行为。 diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml index 9a7b9066ce..6221d9d868 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-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-25-feedback-gated-telemetry-default.md -2026-08-25-feedback-gated-telemetry-default.md: 1a3766ee44907cee330a5b381aad3a91c7efc58f -2026-08-25-feedback-gated-telemetry-default.zh.md: 677574fdb51a38f36db7cc45ba479a36b5bdd629 +2026-08-25-feedback-gated-telemetry-default.md: 772d134da53386292083790148dce738a98f2c0f +2026-08-25-feedback-gated-telemetry-default.zh.md: ea05d4d687bc2270f907983a71f592b880d7449c diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md index 1a3766ee44..772d134da5 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md @@ -10,7 +10,7 @@ Diagnosing a `/feedback` report needs the session data the report describes. Wit ## Decision -The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; recording feedback releases the canonical session-log prefix through that exact event to the configured OTLP endpoint, and the acknowledgement's sharing disclosure states that recording feedback releases the session prefix. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. +The shared dsh base resolves an unset or empty `DSH_TELEMETRY_MODE` to `FEEDBACK_ONLY` instead of `DISABLED`. Nothing is uploaded before the user records `/feedback`; each recorded feedback uploads the not-yet-shared session-log records — from the last handoff through that exact event — to the configured OTLP endpoint, a resumed session shares only its current lifecycle, and the acknowledgement's sharing disclosure states that recording feedback uploads the records not yet shared. `FULL` and `DISABLED` remain explicit `DSH_TELEMETRY_MODE` overrides, any non-empty `DSH_TELEMETRY_DISABLED` remains the authoritative pre-load hard opt-out, and the plugin's own omitted-`mode` default stays `DISABLED`: the default changes only in the shared base's config expression, where deployments already override it. This supersedes the session-backend default of the [default-off decision](2026-08-10-telemetry-default-off.md), accepting the user's explicit feedback action as the release authorization that note required a deployment setting for. That note's hard opt-out and its launcher-feed history remain current, and the [default-mount decision](2026-07-31-web-telemetry-default-mount.md) continues to own the endpoint, batching cadence, and exit-drain settings. @@ -24,6 +24,6 @@ This supersedes the session-backend default of the [default-off decision](2026-0 ## Consequences -- A fresh installation uploads the session-log prefix to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. +- A fresh installation uploads the not-yet-shared session-log records to the production collector when — and only when — the user records `/feedback`; no other trigger uploads. - Released exports remain the raw captured copy: the shipped base mounts no `session-telemetry/record` redaction rule, so they can contain message text, tool arguments and results, and workspace paths. - The sharing disclosure is part of the `/feedback` acknowledgement, so the user reads it after the release has been triggered. A deployment that requires prior informed consent must override the default to `DISABLED` or add a pre-upload confirmation before this default is defensible there. diff --git a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md index 677574fdb5..ea05d4d687 100644 --- a/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决定 -共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;记录反馈时通过该事件把权威会话日志前缀释放到已配置的 OTLP 端点,确认信息中的共享声明会说明记录反馈将释放会话前缀。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 +共享 dsh 基础配置把未设置或为空的 `DSH_TELEMETRY_MODE` 解析为 `FEEDBACK_ONLY` 而不是 `DISABLED`。用户记录 `/feedback` 之前不上传任何数据;每条已记录的反馈把尚未共享的会话日志记录——自上次交接至该事件为止——上传到已配置的 OTLP 端点,恢复的会话只共享当前生命周期,确认信息中的共享声明会说明记录反馈将上传尚未共享的记录。`FULL` 和 `DISABLED` 仍是显式的 `DSH_TELEMETRY_MODE` 覆盖值,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是加载前的强制关闭开关,插件自身省略 `mode` 的默认值仍是 `DISABLED`:默认值只在共享基础配置的配置表达式中改变,部署本来就在那里覆盖它。 本决定取代[默认关闭决定](2026-08-10-telemetry-default-off.zh.md)中会话后端的默认值,把用户显式的反馈动作接受为该决定原本要求由部署设置提供的释放授权。该决定的强制关闭开关和 launcher 上报历史仍然有效,端点、批处理节奏和退出排空设置仍由[默认挂载决定](2026-07-31-web-telemetry-default-mount.zh.md)持有。 @@ -24,6 +24,6 @@ Status: implemented ## 后果 -- 全新安装只在用户记录 `/feedback` 时把会话日志前缀上传到生产 collector;没有其他触发上传的途径。 +- 全新安装只在用户记录 `/feedback` 时把尚未共享的会话日志记录上传到生产 collector;没有其他触发上传的途径。 - 释放的导出仍是未加工的原始副本:随附基础配置没有挂载 `session-telemetry/record` 脱敏规则,导出可能包含消息文本、工具参数和结果,以及 workspace 路径。 - 共享声明是 `/feedback` 确认信息的一部分,用户读到它时释放已被触发。要求事先知情同意的部署必须把默认值覆盖为 `DISABLED`,或在上传前增加确认步骤,此默认值在那类部署中才站得住。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 9e858e257b..17e6b131b0 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: e2814bbda5249fbf0ca0fb9c7f2e98175c5d4e0c -README.zh.md: e714e08147f5430871c17023e67563eefa85118f +README.md: ae6af4bafde7e08bc49f8cb208a206615e6a4c2b +README.zh.md: 96198b6294f2812cc81c7f627a5ba8c986340c33 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index e2814bbda5..ae6af4bafd 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -89,9 +89,9 @@ New sessions in base-backed profiles default to the `workspace-write` permission ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and disabled session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. -Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and recording feedback releases the session-log prefix through that event. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. +Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and each recorded feedback uploads the session records not yet shared, through that event; a resumed session shares only its current lifecycle. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index e714e08147..96198b6294 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -89,9 +89,9 @@ dsh web --help ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和已禁用的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 -会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,记录反馈时通过该事件释放会话日志前缀。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 +会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,每条已记录的反馈通过该事件上传尚未共享的会话记录;恢复的会话只共享当前生命周期。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts new file mode 100644 index 0000000000..7ba2f881ce --- /dev/null +++ b/apps/web/tests/feedback-release.e2e.ts @@ -0,0 +1,137 @@ +// Keyless assembled-browser coverage for the shipped FEEDBACK_ONLY default +// over the Web bundles and the real host wire. The scaffold mounts the +// shipped telemetry row in FEEDBACK_ONLY mode against this suite's own +// loopback mock collector, so the default release path is real: /feedback +// releases the session records through that event (exactly one OTLP request, +// carrying the drive prompt and the feedback text), the acknowledgement pins +// the feedback-gated disclosure sentence, and a second feedback releases only +// the records since the first handoff — the earlier prompt does not repeat. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { createServer, type Server } from 'node:http' +import { once } from 'node:events' +import { gunzipSync } from 'node:zlib' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/feedback-release', import.meta.url)) +// The release path needs only a settled ordinary turn, so this lane replays +// the feedback-command scenario's recorded session (declared as this +// manifest's `session.source`) instead of recording a duplicate. +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/feedback-command/session.jsonl', import.meta.url)) +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const MODE = webSnapshotMode() + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: feedback-gated release under the shipped default mode', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let collector: Server + const uploads: string[] = [] + + beforeAll(async () => { + collector = createServer((request, response) => { + const chunks: Buffer[] = [] + request.on('data', chunk => chunks.push(chunk as Buffer)) + request.on('end', () => { + const raw = Buffer.concat(chunks) + uploads.push((request.headers['content-encoding'] === 'gzip' ? gunzipSync(raw) : raw).toString()) + response.writeHead(200, { 'content-type': 'application/json' }).end('{}') + }) + }) + collector.listen(0, '127.0.0.1') + await once(collector, 'listening') + const address = collector.address() + if (address === null || typeof address === 'string') throw new Error('collector has no port') + scaffold = await launchWebScaffold({ + telemetryUrl: `http://127.0.0.1:${address.port}/v1/logs`, + telemetryMode: 'FEEDBACK_ONLY', + // The replayed session.jsonl belongs to the feedback-command scenario; + // comparing (or refreshing) the persisted session here would rewrite + // that shared source with this lane's feedback events. The release + // evidence lives in this lane's golden and collector assertions. + compareReplaySession: false, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + collector?.close() + collector?.closeAllConnections() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-drive')) + if (MODE !== 'record') { + // Drift guard: the shared fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + // Re-records the SHARED feedback-command session this lane replays. + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + + it.skipIf(MODE === 'record')('releases the session records through the feedback and pins the disclosure', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release')) + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + expect(uploads).toEqual([]) + const input = page.locator('textarea').first() + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/recording feedback uploads the session records not yet shared/).count()).toBe(1) + + // FEEDBACK_ONLY releases through the committed feedback event: exactly + // one request reaches the collector, carrying the whole unshared range. + await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(1) + expect(uploads[0]).toContain('the diff view is unreadable') + expect(uploads[0]).toContain(PROMPT) + + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('releases only the records since the last handoff on a second feedback', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-release-suffix')) + const input = page.locator('textarea').first() + await input.fill('/feedback the second remark') + await input.press('Enter') + await expect.poll(() => uploads.length, { timeout: 15_000 }).toBe(2) + // Suffix semantics: the second release starts after the first feedback's + // handoff, so the drive prompt already shared must not repeat. + expect(uploads[1]).toContain('the second remark') + expect(uploads[1]).not.toContain(PROMPT) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ack.expected.md']) + }) +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index a4e1adc07b..d5c5f16fee 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -319,12 +319,14 @@ export interface LaunchOptions { default: string } /** - * Mount the shipped telemetry row in FULL mode against this exporter URL - * instead of disabling it. Used to pin a real backend disclosure in - * assembled coverage; point the URL at a local dead endpoint so no record - * leaves the process. + * Mount the shipped telemetry row against this exporter URL instead of + * disabling it. Used to pin a real backend disclosure in assembled + * coverage; point the URL at a local endpoint (a dead port, or a scenario's + * own mock collector) so no record leaves the machine. */ telemetryUrl?: string + /** Uploading mode for the mounted telemetry row. Defaults to `FULL`. */ + telemetryMode?: 'FULL' | 'FEEDBACK_ONLY' /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -487,7 +489,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { const test = await harness('feedback-only') await expect(run(test, ' gated sharing')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`, + text: `Feedback recorded for session ${test.session.id}\nAnonymous user: ${USER_ID}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared.`, }) expect(feedbackTexts(test.session)).toEqual(['gated sharing']) }) diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md new file mode 100644 index 0000000000..47326f6769 --- /dev/null +++ b/snapshots/web/feedback-release/ack.expected.md @@ -0,0 +1,46 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - img + - text: Standard mode + - button "Session log": + - text: Session log + - img + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Good response": + - img +- button "Bad response": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared." +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/snapshots/web/feedback-release/snapshot.yml b/snapshots/web/feedback-release/snapshot.yml new file mode 100644 index 0000000000..40ee5c4a20 --- /dev/null +++ b/snapshots/web/feedback-release/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: feedback-release +profile: web +composition: web-default +recording: live +session: + source: ../feedback-command/session.jsonl +header: + class: web-default diff --git a/tsconfig.host.json b/tsconfig.host.json index 109addfd96..6f0eb4019d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -68,6 +68,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/schedule-after.e2e.ts", "apps/web/tests/feedback-command.e2e.ts", + "apps/web/tests/feedback-release.e2e.ts", "apps/web/tests/goal-command-presentation.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", From 66f2938b6313058205eb57237c3581bca7a412f5 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 10:14:20 +0800 Subject: [PATCH 003/130] fix(web): adopt authenticated scaffold URL and post-merge golden in feedback-release lane --- apps/web/tests/feedback-release.e2e.ts | 2 +- snapshots/web/feedback-release/ack.expected.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index 7ba2f881ce..256555944f 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -66,7 +66,7 @@ describe('web e2e: feedback-gated release under the shipped default mode', () => browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }, 120_000) diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index 47326f6769..18103c07f7 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -9,6 +9,10 @@ - tablist: - tab "Chat" [selected] - tab "Trajectory" +- button "System prompt": + - img + - img + - text: System prompt - text: Reply with the single word LIGHTHOUSE and stop. {{clock}} - button "Copy": - img From 98da332260aea9ac881422b4358172335a46affd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:48:33 +0800 Subject: [PATCH 004/130] =?UTF-8?q?feat(session-controller):=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9C=AC=E5=9C=B0=E6=8F=90=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E4=B8=8E=20rpcId=20=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beginSubmission 在 prompt 之前同步把本地提交回显写入 SessionSnapshot.pendingSubmissions; durable user/message(source.rpcId)或队列投影(SessionQueuedItem.rpcId)到达后延迟一帧退休, prompt 失败与放弃立即退休并回调 onRetire。fixture 的 prompt 同步回显 requestId。 --- .../src/client/contract/session.ts | 45 +++- .../src/client/contract/snapshot.ts | 34 +++ .../session-controller/src/client/index.ts | 11 +- .../src/client/sessions/queue-mirror.ts | 1 + .../src/client/sessions/session.ts | 134 +++++++++- .../api/session-controller/src/control.ts | 8 + packages/api/session-controller/src/types.ts | 2 + ...session-pending-submissions.client.spec.ts | 238 ++++++++++++++++++ .../client/connection/src/client/fixture.ts | 9 +- .../tests/ui-session.client.spec.ts | 1 + .../ui-trajectory/tests/views.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + .../client-runtime/src/fixtures.ts | 1 + .../client-runtime/src/sessions.ts | 19 +- 14 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 packages/api/session-controller/tests/session-pending-submissions.client.spec.ts diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 6ac4182bb9..9b8ed3f7ec 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -12,9 +12,37 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction } from '../../types.ts' +import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' -import type { SessionSnapshot } from './snapshot.ts' +import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' + +/** + * Why a local submission echo left the snapshot: `observed` when its durable + * `user/message` event or host queue occurrence arrived (with the admitted + * image references in prompt order), `failed` when the prompt was rejected, + * threw, or was aborted before acceptance. + */ +export type PendingSubmissionRetirement = + | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { readonly reason: 'failed' } + +/** Input registering one local submission echo ahead of its prompt call. */ +export interface BeginSubmissionInput { + /** Prompt text exactly as the upcoming prompt will send it. */ + readonly text: string + /** Ordered image previews matching the upcoming prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] + /** Settlement callback fired exactly once when the echo retires. */ + readonly onRetire?: (retirement: PendingSubmissionRetirement) => void +} + +/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */ +export interface SubmissionHandle { + /** The prompt RPC identity; pass it to {@link ISession.prompt}. */ + readonly requestId: SessionRequestId + /** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */ + abandon(): void +} /** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ export interface ProjectionsFace { @@ -33,16 +61,29 @@ export interface ISession { readonly sessionId: SessionId /** Host-computed projection values by key (the useProjection seat). */ readonly projections: ProjectionsFace + /** + * Register one local submission echo in `snapshot.pendingSubmissions`, + * synchronously, before the caller serializes and sends the prompt. The + * echo retires when a durable `user/message` event or queue occurrence + * carrying the returned identity arrives, or when the identified prompt + * call fails. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle /** * Send a prompt into the session. * @param content - text plus browser-owned temporary image uploads. * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> /** * Resolve one durable image referenced by this session. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index dff2317359..85b5a3013e 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -3,6 +3,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionRequestId } from '../../types.ts' import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ @@ -10,11 +11,42 @@ export interface QueuedMessage { readonly id: MessageId readonly messageId: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */ + readonly rpcId?: SessionRequestId readonly content: readonly ContentBlock[] readonly preview: string readonly text: string | null } +/** One image displayed by a local submission echo before durable admission. */ +export interface PendingSubmissionImage { + /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ + readonly previewUrl: string + /** Browser file name, when the file had one. */ + readonly name?: string + /** Intrinsic pixel width, when the submitter has probed it. */ + readonly width?: number + /** Intrinsic pixel height, when the submitter has probed it. */ + readonly height?: number +} + +/** + * One local prompt-submission echo: inserted synchronously when a submission + * begins, so the conversation can show the message before serialization, + * transport, and durable admission complete. Client-memory only — reload and + * reconnect rebuild the conversation from durable events alone. + */ +export interface PendingSubmission { + /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ + readonly requestId: SessionRequestId + /** Client wall-clock ms when the submission began. */ + readonly time: number + /** Prompt text exactly as it will be sent (one text block). */ + readonly text: string + /** Ordered image previews matching the prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] +} + /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' @@ -28,6 +60,8 @@ export interface PromptError { export interface SessionSnapshot { readonly sessionId: SessionId readonly queue: readonly QueuedMessage[] + /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ + readonly pendingSubmissions: readonly PendingSubmission[] readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 7fa6ede0d0..965b5e2bb8 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -40,7 +40,14 @@ export type { SessionProjectionMap, UseProjection, } from './sessions/projection-store.ts' -export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { + BeginSubmissionInput, + ISession, + PendingSubmissionRetirement, + ProjectionsFace, + SessionFace, + SubmissionHandle, +} from './contract/session.ts' export type { ISessions } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { @@ -53,6 +60,8 @@ export type { } from './contract/events.ts' export type { OpenState, + PendingSubmission, + PendingSubmissionImage, PromptError, QueuedMessage, SessionSnapshot, diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 209349af2c..2a9f274b28 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -43,6 +43,7 @@ export class SessionQueueMirror { id: item.id, messageId: item.message.id, placement: item.placement, + ...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }), content, preview: previewOf(content), text: textOf(content), diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 182d8514d9..acf671b9e9 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -24,9 +24,11 @@ import type { } from '../../types.ts' import type { ClientFailure, ClientResult } from '../contract/result.ts' import { transportResult } from '../contract/result.ts' -import type { SessionFace } from '../contract/session.ts' import type { - OpenState, PromptError, SessionSnapshot, + BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, +} from '../contract/session.ts' +import type { + OpenState, PendingSubmission, PromptError, SessionSnapshot, } from '../contract/snapshot.ts' import { MutableSessionEventSource } from '../contract/events.ts' import type { @@ -101,6 +103,14 @@ export class Session implements SessionFace { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null + /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ + private pendingSubmissions: readonly PendingSubmission[] = [] + /** Per-echo settlement state; `retiring` latches the first observation so a + * queue frame and its durable event cannot both retire one echo. */ + private readonly submissionSettlements = new Map void) | undefined + retiring: boolean + }>() /** Owns the addressed page/follow lifecycle while this Session is open. */ private events: SessionEventStream | undefined @@ -172,16 +182,42 @@ export class Session implements SessionFace { // ---- Operations ---- + /** + * Register one local submission echo (see the ISession declaration). + * Synchronous through markDirty: the echo is in the very next snapshot, so + * the conversation can paint it before the caller starts serializing. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle { + const requestId = randomUUID() as SessionRequestId + this.pendingSubmissions = [...this.pendingSubmissions, { + requestId, + time: Date.now(), + text: input.text, + images: input.images, + }] + this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) + // The blank → engaging edge flips here, ahead of prompt(): the composer + // docks and the echo renders on the click's own frame. + this.promptAttempted = true + this.notifier.markDirty() + return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } } + } + /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns the prompt result (also mirrored into promptError on failure). */ async prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> { this.promptError = null this.lastAgentError = null @@ -196,7 +232,7 @@ export class Session implements SessionFace { if (this.address === undefined) { const clientTimeZone = resolvedClientTimeZone() result = toSessionResult(await this.remote.session.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, sessionId: this.sessionId, mode, content, @@ -236,6 +272,7 @@ export class Session implements SessionFace { result = transportResult(error) } if (!result.ok) { + if (requestId !== undefined) this.retireFailedSubmission(requestId) this.promptError = { op: 'send', error: result.error } this.notifier.markDirty() return result @@ -434,6 +471,7 @@ export class Session implements SessionFace { */ replaceControl(queue: readonly SessionQueuedItem[]): void { this.queueMirror.replace(queue) + this.observeSubmissionQueue(queue) this.notifier.markDirty() } @@ -443,6 +481,7 @@ export class Session implements SessionFace { */ handleControlFrame(frame: Extract): void { this.queueMirror.replace(frame.items) + this.observeSubmissionQueue(frame.items) this.notifier.markDirty() } @@ -523,6 +562,12 @@ export class Session implements SessionFace { * @returns when the Remote iterator has completed teardown. */ async dispose(): Promise { + // Unsettled echoes retire as failed so their owners can restore or + // release browser resources; echoes already scheduled as observed keep + // that settlement. + for (const requestId of [...this.submissionSettlements.keys()]) { + this.retireFailedSubmission(requestId) + } this.openGeneration++ const events = this.events this.events = undefined @@ -581,6 +626,7 @@ export class Session implements SessionFace { if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false if (projections !== undefined) this.projections.seed(projections) this.eventSource.replace(entries, hasMore) + for (const entry of entries) this.observeSubmissionEvent(entry.event) this.notifier.markDirty() } @@ -598,9 +644,70 @@ export class Session implements SessionFace { if (event.type === 'turn/start') this.firstPromptPendingTurn = false const queueChanged = this.queueMirror.acceptDurable(event) this.eventSource.append(entry) + // After the feed append: the conversation assembly's animation frame is + // registered by the feed subscribers above, so the echo-retirement frame + // scheduled here always runs after the durable node became renderable. + this.observeSubmissionEvent(event) return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn } + /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ + private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { + if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return + // Structural read: window entries may be compact history records, so the + // fields are narrowed rather than trusted (same posture as Conversation + // assembly matchers). + const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined + const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined + if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + } + + /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ + private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void { + if (this.submissionSettlements.size === 0) return + for (const item of items) { + if (item.rpcId !== undefined) { + this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + } + } + } + + /** + * Latch one observed settlement and remove the echo an animation frame + * later. The delay keeps the echo in the snapshot until the frame in which + * the durable node (whose assembly frame was registered first) is + * renderable; the render-time rpcId dedupe hides the one-frame overlap. + */ + private scheduleObservedRetirement( + requestId: SessionRequestId, + attachments: readonly ImageAttachmentRef[], + ): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }) }) + } + + /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ + private retireFailedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + this.finishSubmission(requestId, { reason: 'failed' }) + } + + /** Single removal point: drop the echo, publish, then notify the owner. */ + private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void { + const settlement = this.submissionSettlements.get(requestId) + /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */ + if (settlement === undefined) return + this.submissionSettlements.delete(requestId) + this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId) + this.notifier.markDirty() + settlement.onRetire?.(retirement) + } + /** Publish a terminal background failure only while this stream still owns the Session. */ private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { if (generation !== this.openGeneration || this.events !== events) return @@ -617,6 +724,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, queue: this.queueMirror.snapshot(), + pendingSubmissions: this.pendingSubmissions, running: this.running, subagent: this.address === undefined ? null @@ -644,6 +752,26 @@ export class Session implements SessionFace { } } +/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ +function scheduleFrame(fn: () => void): void { + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) + else setTimeout(fn, 0) +} + +/** Image attachment references in one structurally-read content block list, in block order. */ +function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { + if (!Array.isArray(content)) return [] + const refs: ImageAttachmentRef[] = [] + for (const block of content) { + if (typeof block !== 'object' || block === null) continue + const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } + if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef) + } + } + return refs +} + /** Convert a terminal Session stream failure to the Client error vocabulary. */ function openFailure(error: unknown): ClientFailure { const failure = sessionStreamFailure(error) diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 4068b5536d..624c9a9493 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -188,16 +188,24 @@ function queueItems( ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ...project('next-step').map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ] } +/** Prompt-RPC identity carried by a browser-submitted message's user source. */ +function promptRpcId(message: UserMessage): Pick { + const source = message.source + return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} +} + function jobView(job: JobSnapshot): SessionJob { return { id: job.id, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c045e00707..e9e416777c 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -432,6 +432,8 @@ export type SessionFollowFrame = export interface SessionQueuedItem { readonly id: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */ + readonly rpcId?: SessionRequestId /** JSON-safe message fields consumed by pending-queue presentation. */ readonly message: { readonly id: MessageId diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts new file mode 100644 index 0000000000..fa0e69d89a --- /dev/null +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -0,0 +1,238 @@ +/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { Session } from '../src/client/sessions/session.ts' +import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' +import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' +import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { historyValue } from './event-script.client.ts' + +const SID = 'fk-s1' as SessionId + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { + return { api, session: new Session(SID, api, fakeRemote(api)) } +} + +function imageRef(id: string): ImageAttachmentRef { + return { + attachmentId: id, + mediaType: 'image/png', + bytes: 1, + width: 2, + height: 2, + } as unknown as ImageAttachmentRef +} + +/** A durable browser-prompt user/message whose source echoes `rpcId`. */ +function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { + return { + seq, + time: 1_700_000_000_000 + seq, + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [ + ...refs.map(attachment => ({ type: 'image' as const, attachment })), + { type: 'text' as const, text: '发送' }, + ], + source: { kind: 'user', rpcId } as MessageSource, + }), + } as unknown as SessionEvent +} + +function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { + return { + id: 'm-queued' as SessionQueuedItem['id'], + placement: 'queued', + rpcId, + message: { + id: 'm-queued' as SessionQueuedItem['id'], + content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + }, + } +} + +/** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ +function settleFrames(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('beginSubmission', () => { + it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) + const handle = session.beginSubmission({ + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }) + expect(session.getSnapshot().promptAttempted).toBe(true) + expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ + requestId: handle.requestId, + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }]) + }) + + it('abandon retires the echo as failed exactly once', () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '放弃', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + handle.abandon() + handle.abandon() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) +}) + +describe('prompt-coupled retirement', () => { + it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '失败的', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) + expect(result.ok).toBe(false) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' }) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) + + it('sends the echo identity as the prompt requestId', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '带 id', images: [] }) + await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) + expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) + }) + + it('an unidentified prompt failure leaves registered echoes alone', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + session.beginSubmission({ text: '还在', images: [] }) + await session.prompt([{ type: 'text', text: '另一个' }], 'queue') + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + }) +}) + +describe('observed retirement', () => { + it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '发送', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-1')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never }) + // Synchronously after the append the echo is still in the snapshot; the + // render-time dedupe owns the overlap frame. + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '排队', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-q')] + session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + // The queue projection keeps the correlation id for render-time dedupe. + expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) + }) + + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '重连', images: [] }) + api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) + await session.open() + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) + + it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '先观察', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + handle.abandon() + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) + }) + + it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + frames.push(fn) + return frames.length + }) + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const handle = session.beginSubmission({ text: '帧', images: [] }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(frames).toHaveLength(1) + frames[0]?.(0) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) + +describe('disposal', () => { + it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] + const observed = session.beginSubmission({ + text: '已观察', + images: [], + onRetire: retirement => retirements.push({ text: '已观察', retirement }), + }) + session.beginSubmission({ + text: '未settle', + images: [], + onRetire: retirement => retirements.push({ text: '未settle', retirement }), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never }) + await session.dispose() + await settleFrames() + expect(retirements).toEqual([ + { text: '未settle', retirement: { reason: 'failed' } }, + { text: '已观察', retirement: { reason: 'observed', attachments: [] } }, + ]) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ce2fcdae6a..d3be8087a6 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2757,9 +2757,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) return { type: 'image', attachment } }) + // The host echoes the prompt's requestId as the user source's rpcId; + // the Session object retires its local submission echo on it. The + // user-rpc source member is declared by dsh-api-session-controller, + // which this standalone fixture does not import — hence the assertion. + const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource if (mode === 'steer' && replays.has(id)) { // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) return sessionOk({ accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 @@ -2772,7 +2777,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (plan.wanted !== null && plan.wanted !== plan.active) { append(id, { type: 'plan/mode', data: { active: plan.wanted } }) } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) // Capacity parallel of the host token-meter's request/context record: // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 97946f4c1d..ce6d154c0c 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { const snapshot = createSnapshotStore({ sessionId: id, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 4aa01e9ca6..4bf4793de0 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -111,6 +111,7 @@ function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapsh return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a650dd6a5b..c2ee66b3b0 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -25,6 +25,7 @@ type AttentionState = Parameters {}, + } + } + + private submissionSeq = 0 + /** * Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it. * @param _attachmentId - opaque durable attachment id. From 390dad6138d1ddbbc129cf5ba4a7c49305f7dccf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:49:16 +0800 Subject: [PATCH 005/130] =?UTF-8?q?feat(ui-conversation):=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=8F=91=E9=80=81=E6=94=B9=E4=B8=BA=E4=B9=90=E8=A7=82?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=B9=B6=E6=8E=A5=E5=85=A5=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enter 即清空草稿并解冻输入框,默认发送作为 detached attempt 并发运行; sink-settled 失败时仅还原未被覆盖的空草稿与图片;sendSession 在序列化前注册 提交回显并在绘制让步后再编码(FileReader 原生 base64);观察退休时把预览 URL 移交 HistoricalImageCache,正式消息节点零往返显示。 --- .../src/client/contract/input.ts | 26 ++++- .../src/client/contract/slots.ts | 30 ++++- .../src/client/conversation/assembly.ts | 13 +++ .../client/conversation/historical-images.ts | 23 ++++ .../ui-conversation/src/client/index.ts | 3 +- .../src/client/input/facade.ts | 73 +++++++----- .../src/client/input/machine.ts | 74 ++++++++++-- .../ui-conversation/src/client/service.ts | 110 ++++++++++++++++-- .../tests/apply-inject.client.spec.tsx | 12 +- .../conversation-registry.client.spec.ts | 1 + .../tests/input-bar.client.spec.tsx | 21 +++- .../tests/input-machine.client.spec.ts | 43 +++++-- .../tests/input-matrix.client.spec.tsx | 5 +- .../input-reference-submit.client.spec.ts | 20 ++-- 14 files changed, 370 insertions(+), 84 deletions(-) diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 7d50fa8124..0bc412e4dd 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -375,9 +375,11 @@ export interface InputState { /** * One in-flight submission attempt: the ONLY id concept in the submit plane. - * Created on enter; carried by adjudicated/submit-settled events; stale - * attempts are dropped (anti-backwash). release/session teardown aborts the - * current attempt, keeping the promise bounded. + * Created on enter; carried by adjudicated/submit-settled/sink-settled + * events; stale attempts are dropped (anti-backwash). Command attempts hold + * the single frozen in-flight slot; default-sink attempts run detached and + * concurrently. release/session teardown aborts them all, keeping every + * promise bounded. */ export interface SubmitAttempt { readonly seq: number @@ -421,6 +423,13 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } + /** + * Settlement of one detached default-sink send. Independent of phase and of + * the command-plane in-flight slot: the composer committed optimistically at + * enter, so failure restores the enter-time draft and occurrences only while + * the composer is still untouched (empty plain draft). + */ + | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -433,5 +442,14 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } + /** Detached default send. The machine committed the composer clear at enter; + * `occurrences` snapshots the reference table serialization needs (the live + * table was cleared with the draft). */ + | { + readonly type: 'default-sink' + readonly attempt: SubmitAttempt + readonly draft: string + readonly occurrences: readonly Occurrence[] + readonly mode: InputSubmitMode + } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7865d5906..d79cdc9de1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -28,6 +28,10 @@ export interface ComposerAttachment { id: DraftAttachmentId file: File previewUrl: string + /** Intrinsic pixel width, filled asynchronously by the intake header probe. */ + width?: number + /** Intrinsic pixel height, filled asynchronously by the intake header probe. */ + height?: number } /** Input state handed to the optional attachment presentation plugin. */ @@ -44,11 +48,29 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } -/** Durable image group handed to the optional attachment presentation plugin. */ +/** + * One image inside a message record: a durable admitted reference, or the + * local preview of a submission echo whose admission is still in flight. + */ +export type MessageImageSource = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + /** Browser-owned preview URL (lifecycle stays with the submitter). */ + readonly url: string + readonly name?: string + /** Intrinsic pixel width, when the intake probe has resolved it. */ + readonly width?: number + /** Intrinsic pixel height, when the intake probe has resolved it. */ + readonly height?: number + } + } + +/** Message image group handed to the optional attachment presentation plugin. */ export interface MessageImagesOwnerProps { - /** Durable image references in source order. */ - images: readonly { readonly attachment: ImageAttachmentRef }[] - /** Session-authorized image URL loader. */ + /** Durable references or submission-echo previews in source order. */ + images: readonly MessageImageSource[] + /** Session-authorized image URL loader for the durable arm. */ loadImage: (attachment: ImageAttachmentRef) => Promise /** Horizontal placement inside the owning record. */ align: 'start' | 'end' diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index ffb132cd92..a279cc8409 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -214,6 +214,19 @@ export class UiConversation extends Service { return this.images.resolve(sessionId, attachment) } + /** + * Adopt an already-displayable URL for one durable reference (see + * HistoricalImageCache.seed): the transcript node then renders it without a + * byte round-trip. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL displays. + * @param url - browser URL to adopt. + * @returns whether the cache took URL ownership. + */ + seedImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + return this.images.seed(sessionId, attachment, url) + } + /** * Canonicalize one `request/header` event against the previous prompt state. * diff --git a/packages/client/ui-conversation/src/client/conversation/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts index 602b104c1d..58216febab 100644 --- a/packages/client/ui-conversation/src/client/conversation/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -67,6 +67,29 @@ export class HistoricalImageCache { return pending } + /** + * Adopt an already-displayable URL for one durable reference (a submission + * echo's preview whose bytes are the just-admitted image). Ownership moves + * to this cache: the URL is revoked with the Session scope like a fetched + * one, and later resolve() calls reuse it without a byte round-trip. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL displays. + * @param url - browser URL to adopt. + * @returns whether the cache took ownership (false: entry already present or unknown session — the caller keeps the URL). + */ + seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + if (this.disposed) return false + const key = `${sessionId}:${attachment.attachmentId}` + if (this.entries.has(key)) return false + const binding = this.sessions.binding(sessionId) + if (binding === undefined) return false + this.bindScope(sessionId, binding.ctx) + const generation = this.generations.get(sessionId) ?? 0 + this.urls.add(url) + this.entries.set(key, { sessionId, generation, pending: Promise.resolve(url) }) + return true + } + private bindScope(sessionId: SessionId, scope: Context): void { if (this.scopeDisposers.has(sessionId)) return const dispose = scope.effect(() => () => { diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 67e2f28611..83a8839976 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -53,7 +53,8 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, + MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, + UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 6d48be9371..28a9985a01 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -13,7 +13,7 @@ import { import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, - InputTriggerController, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, + InputTriggerController, Occurrence, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -100,8 +100,6 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] - /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ - private imageSendInFlight = false private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined @@ -208,17 +206,19 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + if (this.snapshot.phase === 'plain') { + // Optimistic image-only send: the rail clears now; a failed admission + // restores the same ids (they stay registered until release). const imageIds = [...this.imageIds] - this.imageSendInFlight = true + this.commitSend(imageIds) void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { - this.imageSendInFlight = false - if (this.disposed) return - if (outcome.kind === 'success') this.commitSend(imageIds) - else if (outcome.text !== undefined) this.notify('error', outcome.text) + if (this.disposed || outcome.kind === 'success') return + this.restoreImages(imageIds) + if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - this.imageSendInFlight = false - if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + if (this.disposed) return + this.restoreImages(imageIds) + this.notify('error', error instanceof Error ? error.message : String(error)) }) } return @@ -438,7 +438,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.attempt, fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.occurrences, fx.mode) return } default: @@ -449,15 +449,21 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each * inline reference range to its owner's model form via the session controller's - * codec routing. Owner missing / serialize failure / disposal blocks the - * send — notice + draft and chips retained, never a silent downgrade to - * the clipboard text. Chip-free drafts skip the async detour. + * codec routing. The composer committed at enter, so the draft images clear + * here (captured for the send) and a failure — owner missing, serialize + * rejection, transport, or admission — restores them beside the machine's + * untouched-draft restore. Chip-free drafts skip the async detour. */ - private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { + private sinkSerialized( + attempt: SubmitAttempt, + draft: string, + occurrences: readonly Occurrence[], + mode: InputSubmitMode, + ): void { const imageIds = [...this.imageIds] - const occurrences = this.core.state.occurrences + this.imageIds = [] if (occurrences.length === 0) { - this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return } const inputTriggers = this.deps.inputTriggers?.() @@ -481,32 +487,30 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) }, (error: unknown) => { controller.abort() if (this.dead(attempt)) return + this.restoreImages(imageIds) const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + this.run(this.core.dispatch({ type: 'sink-settled', attempt, ok: false, message })) }, ) } - /** Settle one admission attempt; successful sends consume only their captured images. */ - private settleSubmit( + /** Settle one detached default send; a failure returns its captured images to the rail. */ + private settleSink( attempt: SubmitAttempt, pending: Promise, - imageIds: readonly DraftAttachmentId[] = [], + imageIds: readonly DraftAttachmentId[], ): void { pending.then( (outcome) => { if (this.dead(attempt)) return - if (outcome.kind === 'success' && imageIds.length > 0) { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) - } + if (outcome.kind !== 'success') this.restoreImages(imageIds) this.run(this.core.dispatch({ - type: 'submit-settled', + type: 'sink-settled', attempt, ok: outcome.kind === 'success', outcome, @@ -514,8 +518,9 @@ export class SessionInputShell implements SessionInput { }, (error: unknown) => { if (this.dead(attempt)) return + this.restoreImages(imageIds) this.run(this.core.dispatch({ - type: 'submit-settled', + type: 'sink-settled', attempt, ok: false, message: error instanceof Error ? error.message : String(error), @@ -524,6 +529,16 @@ export class SessionInputShell implements SessionInput { ) } + /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ + private restoreImages(imageIds: readonly DraftAttachmentId[]): void { + if (imageIds.length === 0) return + const current = new Set(this.imageIds) + const restored = imageIds.filter(id => !current.has(id)) + if (restored.length === 0) return + this.imageIds = [...restored, ...this.imageIds] + this.publish() + } + /** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */ private adjudicate(attempt: SubmitAttempt, draft: string): void { const inputTriggers = this.deps.inputTriggers?.() diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 75ae348d8f..4078563685 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -125,6 +125,12 @@ export class InputMachine { readonly attempt: SubmitAttempt readonly controller: AbortController } | undefined + /** Detached default-sink sends by attempt seq: the composer already committed; settlement only restores on failure. */ + private readonly detached = new Map() private log: Transaction[] = [] private redoStack: Transaction[] = [] /** Open single-char typing run: the next contiguous char within the window coalesces. */ @@ -186,6 +192,7 @@ export class InputMachine { case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) + case 'sink-settled': return this.onSinkSettled(ev) case 'send-committed': return this.onSendCommitted() case 'release': return this.onRelease() default: return unreachable(ev) @@ -480,6 +487,30 @@ export class InputMachine { return attempt } + /** + * Detach one default send and commit the composer clear in the same + * transaction: the draft, occurrence table, and undo history go now (a sent + * draft must not resurrect through Ctrl/Cmd-Z), while the snapshots ride + * the detached record so a failed settlement can restore an untouched + * composer. The phase stays 'plain' — typing and further sends continue + * during the flight. + */ + private detachSink(attempt: SubmitAttempt, controller: AbortController): InputEffect { + const occurrences = this.occurrences + this.detached.set(attempt.seq, { controller, draftSnapshot: attempt.draftSnapshot, occurrences }) + this.phase = 'plain' + this.claim = undefined + if (this.draft === attempt.draftSnapshot) { + this.occurrences = [] + this.adopt('') + this.log = [] + this.redoStack = [] + } + this.typingRun = undefined + this.paste = undefined + return { type: 'default-sink', attempt, draft: attempt.draftSnapshot, occurrences, mode: attempt.mode } + } + private onEnter(mode: InputSubmitMode): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { @@ -496,9 +527,10 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - const attempt = this.beginAttempt(mode) - this.phase = 'submitting' - return [{ type: 'default-sink', attempt, draft: this.draft, mode }] + const controller = new AbortController() + this.seq += 1 + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode } + return [this.detachSink(attempt, controller)] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -517,13 +549,8 @@ export class InputMachine { // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. if (outcome === undefined) { - this.phase = 'submitting' - return [{ - type: 'default-sink', - attempt, - draft: attempt.draftSnapshot, - mode: attempt.mode, - }] + this.inflight = undefined + return [this.detachSink(attempt, flight.controller)] } this.inflight = undefined this.phase = 'plain' @@ -577,6 +604,31 @@ export class InputMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } + /** + * Settle one detached default send. Success has nothing left to commit (the + * clear happened at enter); failure restores the enter-time draft and + * occurrence table, but only into a still-untouched composer — an empty + * plain draft — so content typed during the flight always wins. + */ + private onSinkSettled(ev: Extract): InputEffect[] { + const record = this.detached.get(ev.attempt.seq) + if (record === undefined) return [] + this.detached.delete(ev.attempt.seq) + if (ev.ok) { + return ev.outcome?.text !== undefined + ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] + : [] + } + if (this.phase === 'plain' && this.draft === '') { + this.occurrences = record.occurrences + this.adopt(record.draftSnapshot) + this.typingRun = undefined + this.paste = undefined + } + const text = ev.message ?? ev.outcome?.text + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] + } + /** Cut undo state after an accepted image-only send. */ private onSendCommitted(): InputEffect[] { if (this.phase !== 'plain') return [] @@ -595,6 +647,8 @@ export class InputMachine { this.inflight.controller.abort() this.inflight = undefined } + for (const record of this.detached.values()) record.controller.abort() + this.detached.clear() this.phase = 'plain' this.claim = undefined this.typingRun = undefined diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6b7d94829f..8879be7740 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -9,11 +9,13 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { + ISessions, PendingSubmissionRetirement, SessionFace, +} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' @@ -72,6 +74,48 @@ function browserDraftAttachment(file: File): ComposerAttachment { } } +/** + * Fill the draft's intrinsic dimensions once the browser parses the image + * header (a metadata read off the preview URL, not a full decode). Failures + * and non-browser runtimes leave them absent — consumers size those images + * from CSS constraints instead. + */ +function probeDimensions(attachment: ComposerAttachment): void { + if (typeof Image !== 'function') return + const probe = new Image() + probe.onload = () => { + attachment.width = probe.naturalWidth + attachment.height = probe.naturalHeight + } + probe.src = attachment.previewUrl +} + +/** Resolve after the browser paints the frame in which a just-published submission echo renders. */ +function nextPaint(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { setTimeout(resolve, 0) }) + } else { + setTimeout(resolve, 0) + } + }) +} + +/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ +function base64Of(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const url = reader.result as string + resolve(url.slice(url.indexOf(',') + 1)) + } + reader.onerror = () => { + reject(reader.error ?? new Error('conversation: image read failed')) + } + reader.readAsDataURL(file) + }) +} + /** Unsupported browser-declared image type, localized by the UI boundary. */ export class UnsupportedImageMediaTypeError extends Error { /** Browser-declared MIME value, possibly empty. */ @@ -125,7 +169,12 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. + * Submit ordered draft images with text through one host admission. A local + * submission echo enters the session snapshot synchronously; serialization + * and the prompt round-trip start after the browser can paint it. On the + * echo's observed retirement the draft images hand their preview URLs to + * the durable image cache and leave the registry; on failure they stay + * registered so the composer can restore them. * @param session - target session. * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. @@ -144,12 +193,27 @@ export class ConversationController extends Service implements IConversation { if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) - if (!result.ok) return { kind: 'error' } - this.releaseDraftImages(attachments) - return { kind: 'success' } + const submission = session.beginSubmission({ + text, + images: attachments.map(attachment => ({ + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + })), + onRetire: (retirement) => { this.settleSubmittedImages(session.sessionId, attachments, retirement) }, + }) + let content: Parameters[0] + try { + await nextPaint() + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + } catch (error) { + submission.abandon() + throw error + } + const result = await session.prompt(content, mode, signal, submission.requestId) + return result.ok ? { kind: 'success' } : { kind: 'error' } } /** @@ -162,6 +226,7 @@ export class ConversationController extends Service implements IConversation { return files.map((file) => { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) return attachment }) } @@ -264,6 +329,31 @@ export class ConversationController extends Service implements IConversation { return sessions } + /** + * Settle one submission's draft images when its echo retires. Observed: + * each image leaves the registry, handing its preview URL to the durable + * image cache (seeded under the admitted reference so the transcript node + * renders without a byte round-trip) or revoking it when the cache already + * holds that reference. Failed: nothing changes — the ids stay registered + * for the composer's rail restore. + */ + private settleSubmittedImages( + sessionId: SessionId, + attachments: readonly ComposerAttachment[], + retirement: PendingSubmissionRetirement, + ): void { + if (retirement.reason !== 'observed') return + const uiConversation = this.ctx.get('uiConversation') + attachments.forEach((attachment, index) => { + const live = this.draftAttachments.get(attachment.id) + if (live === undefined) return + this.draftAttachments.delete(attachment.id) + const ref = retirement.attachments[index] + if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + revokePreview(attachment.previewUrl) + }) + } + /** Convert browser files to canonical base64 prompt parts. */ private serializeImages(images: readonly File[]): Promise[0]> { return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) @@ -273,7 +363,7 @@ export class ConversationController extends Service implements IConversation { private async encodeImage(file: File): Promise { return { mediaType: imageMediaType(file.type), - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + data: await base64Of(file), ...(file.name === '' ? {} : { name: file.name }), } } diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index db5bf29baa..b4103dbda4 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -102,10 +102,14 @@ describe('Conversation inject API', () => { actions.setDraft('hello') actions.submit() - await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') }) - expect(b.sessionFake.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), - ) + // Optimistic commit clears the draft at enter; the prompt lands after the + // paint-yield inside the send pipeline. + expect(state.getSnapshot().draft).toBe('') + await vi.waitFor(() => { + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String), + ) + }) b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 20a7a0f7aa..f75e04523e 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -21,6 +21,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SESSION_ID, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 17064a97a5..2ca9b22cf4 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -367,11 +367,28 @@ describe('image draft rail', () => { sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) - expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) + // Optimistic commit: the rail clears at submit, before the admission settles. + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) await act(async () => { settle({ kind: 'success' }) }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + }) + + it('returns an image-only draft to the rail when its admission fails', async () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + ] + const result = bench({ attachments }) + const { textarea, sink } = result + let fail!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { fail = resolve })) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) }) await vi.waitFor(() => { - expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) }) + expect(result.view.getByRole('alert').textContent).toContain('图片发送失败') }) it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 28e7cc4430..c9938c6c10 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -71,13 +71,18 @@ describe('input-machine: plain × enter', () => { expect(m.state.phase).toBe('plain') }) - it('non-command text falls to the default sink', () => { + it('non-command text falls to the default sink and commits the composer clear at enter', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' }) expect(effect.attempt.draftSnapshot).toBe('hello world') - expect(m.state.phase).toBe('submitting') + // Optimistic commit: the send is detached — the composer is already + // cleared, unlocked, and un-undoable while the flight runs. + expect(m.state.phase).toBe('plain') + expect(m.state.draft).toBe('') + expect(m.dispatch({ type: 'undo' })).toEqual([]) + expect(m.state.draft).toBe('') }) it('retains an explicit steer mode on the default sink effect', () => { @@ -134,7 +139,7 @@ describe('input-machine: adjudication outcomes', () => { expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x') }) - it('undefined outcome falls back to the default sink', () => { + it('undefined outcome falls back to the default sink and commits the clear', () => { const m = new InputMachine() const attempt = enterAdjudicating(m, '/unknown thing', 'steer') expect(effectAt( @@ -142,7 +147,8 @@ describe('input-machine: adjudication outcomes', () => { 0, 'default-sink', )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' }) - expect(m.state.phase).toBe('submitting') + expect(m.state.phase).toBe('plain') + expect(m.state.draft).toBe('') }) it("'handled' lands plain with zero effects (popup shell path)", () => { @@ -525,20 +531,35 @@ describe('input-machine: undo / redo', () => { expect(m.state.draft).toBe('') }) - it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => { + it('text typed during the detached flight is the next draft and survives both settlements', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello' }) const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') - m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true }) - expect(m.state.draft).toBe(' world') + expect(m.state.draft).toBe('') + m.dispatch({ type: 'draft-changed', draft: 'world' }) + m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: true }) + expect(m.state.draft).toBe('world') + // Failure with a non-empty composer keeps the typed content: the sent + // draft is NOT restored over it. const n = new InputMachine() n.dispatch({ type: 'draft-changed', draft: 'hello' }) const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') - n.dispatch({ type: 'draft-changed', draft: 'hXello' }) - n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true }) - expect(n.state.draft).toBe('') + n.dispatch({ type: 'draft-changed', draft: 'typed during flight' }) + n.dispatch({ type: 'sink-settled', attempt: second.attempt, ok: false, message: 'boom' }) + expect(n.state.draft).toBe('typed during flight') + }) + + it('a failed detached flight restores the sent draft and occurrences into an untouched composer', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'restore me' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(m.state.draft).toBe('') + const fx = m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: false, message: 'boom' }) + expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }]) + expect(m.state.draft).toBe('restore me') + // A second settlement of the same attempt is a dropped stale event. + expect(m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: false, message: 'again' })).toEqual([]) }) }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index fd00297bee..94e4b81976 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -114,8 +114,9 @@ describe('matrix row: plain', () => { expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) - expect(shell.snapshot.phase).toBe('submitting') - await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + // The detached default send never freezes the composer. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') expect(shell.snapshot.claim).toBeUndefined() }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index b2d1d34335..3a6b886695 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -96,9 +96,12 @@ describe('reference submission', () => { }) shell.submit('queue') - expect(shell.snapshot.phase).toBe('submitting') + // Optimistic commit: the composer clears at enter and stays unlocked + // while the detached flight runs. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('@Research ') }) expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot).toMatchObject({ @@ -111,10 +114,10 @@ describe('reference submission', () => { }) shell.submit('queue') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.draft).toBe('') + expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) }) - expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot.occurrences).toEqual([]) expect(serializeReference).toHaveBeenCalledTimes(2) }) @@ -133,11 +136,12 @@ describe('reference submission', () => { }) chip(shell) shell.submit() + // The serializer rejection restores the committed draft and chip into the + // still-untouched composer. await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('@Research ') }) expect(sink).not.toHaveBeenCalled() - expect(shell.snapshot.draft).toBe('@Research ') expect(shell.snapshot.occurrences).toHaveLength(1) expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', @@ -161,7 +165,9 @@ describe('reference submission', () => { shell.dispose() expect(signal?.aborted).toBe(true) expect(shell.snapshot.phase).toBe('plain') - expect(shell.snapshot.draft).toBe('send this') + // The optimistic commit stands: disposal drops the settlement, so the + // sent draft is not restored into the dying composer. + expect(shell.snapshot.draft).toBe('') }) it('retains a rejected default message without duplicating its prompt error notice', async () => { From cf47b7e05995924a65ed0ac6683e8ecdff6bd781 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:49:56 +0800 Subject: [PATCH 006/130] =?UTF-8?q?feat(web):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E6=98=BE=E5=9C=A8=20Chat=20=E6=B5=81=E5=B0=BE?= =?UTF-8?q?=E5=8D=B3=E6=97=B6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatView 渲染 pendingSubmissions 为用户气泡,按 rpcId 对正式节点与队列行做 渲染期去重,替换原子无闪烁;新增回显跟随滚动;MessageImage/ImageGallery 增加 本地预览 arm,回显图片直接显示 object URL。 --- .../client/ui-attachment/src/MessageImage.tsx | 73 +++++++++++++++---- .../tests/message-image.client.spec.tsx | 22 +++--- .../ui-chat/src/client/chat/ChatView.tsx | 57 ++++++++++++++- .../ui-chat/src/client/chat/MessageItem.tsx | 56 +++++++++++++- .../ui-chat/tests/chat-view.client.spec.tsx | 1 + .../tests/gate-branch-tails.client.spec.tsx | 1 + .../tests/image-labels.client.spec.tsx | 8 +- 7 files changed, 183 insertions(+), 35 deletions(-) diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index c4de8f73a7..43a43c7a48 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -7,6 +7,18 @@ import css from './MessageImage.module.css' /** Loads a session-authorized durable image URL. */ export type ImageLoader = (attachment: ImageAttachmentRef) => Promise +/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ +export type MessageImageSpec = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + readonly url: string + readonly name?: string + readonly width?: number + readonly height?: number + } + } + /** Message-image strings the owner resolves from its own locale namespace. */ export interface MessageImageLabels { /** Fallback display name for an unnamed image. */ @@ -28,11 +40,13 @@ export interface MessageImageLabels { * `object-fit: cover` — and never upscaled past the image's natural size. The * crop anchor keeps the top of very tall images and the left of very wide * ones, where the informative content usually starts. */ -function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } { - const natural = attachment.width / attachment.height +function singleFit( + dimensions: { readonly width: number; readonly height: number }, +): { width: number; height: number; objectPosition: string } { + const natural = dimensions.width / dimensions.height const ratio = Math.min(4, Math.max(0.25, natural)) const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } - const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height) + const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height) return { width: Math.max(1, Math.round(box.width * scale)), height: Math.max(1, Math.round(box.height * scale)), @@ -40,24 +54,35 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num } } +/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */ +function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined { + if ('attachment' in image) return image.attachment + return image.preview.width !== undefined && image.preview.height !== undefined + ? { width: image.preview.width, height: image.preview.height } + : undefined +} + /** * Compact history renderer with retryable loading and click-to-open original * preview. A lone image renders at its `singleFit` size; an image among - * several renders as a fixed 64px square tile. + * several renders as a fixed 64px square tile. The preview arm displays its + * local URL directly — no loader round-trip, no failure/retry surface. * - * @param props.attachment - the durable image reference to load and bound. - * @param props.load - session-authorized URL loader. + * @param props.image - the durable reference to load, or the local preview to display. + * @param props.load - session-authorized URL loader for the durable arm. * @param props.variant - `single` for a message's lone image, `tile` otherwise. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ attachment, load, variant, labels }: { - attachment: ImageAttachmentRef +export function MessageImage({ image, load, variant, labels }: { + image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' labels: MessageImageLabels }) { - const [src, setSrc] = useState(null) + const preview = 'preview' in image ? image.preview : undefined + const attachment = 'attachment' in image ? image.attachment : undefined + const [loaded, setLoaded] = useState(null) const [error, setError] = useState(false) const [open, setOpen] = useState(false) // Retry re-arms the one load effect below, so every attempt — first load or @@ -65,20 +90,30 @@ export function MessageImage({ attachment, load, variant, labels }: { const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) + const dimensions = useMemo(() => dimensionsOf(image), [image]) const fit = useMemo( - () => (variant === 'single' ? singleFit(attachment) : undefined), - [attachment, variant], + () => { + if (variant !== 'single') return undefined + // A preview whose intake probe has not resolved sizes as a square crop; + // the durable replacement restores the exact fit. + return dimensions === undefined + ? { width: 240, height: 240, objectPosition: 'center' } + : singleFit(dimensions) + }, + [dimensions, variant], ) useEffect(() => { + if (attachment === undefined) return let live = true setError(false) - setSrc(null) - void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + setLoaded(null) + void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } }, [attachment, load, attempt]) - const label = attachment.name ?? labels.image + const src = preview?.url ?? loaded + const label = (preview?.name ?? attachment?.name) ?? labels.image if (error) return return ( <> @@ -103,7 +138,7 @@ export function MessageImage({ attachment, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ export function ImageGallery({ images, load, align, labels }: { - images: readonly { attachment: ImageAttachmentRef }[] + images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' labels: MessageImageLabels @@ -113,7 +148,13 @@ export function ImageGallery({ images, load, align, labels }: { return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d7d37bfd97..5972beacdb 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -49,7 +49,7 @@ const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector( describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -64,7 +64,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -74,7 +74,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -84,7 +84,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -96,7 +96,7 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -105,7 +105,7 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -114,7 +114,7 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -123,7 +123,7 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -131,7 +131,7 @@ describe('MessageImage', () => { it('keeps the tile variant on the failed-load retry control', async () => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe('tile') }) @@ -139,13 +139,13 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 0b8591b205..8cf3d2d276 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,8 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { PendingSteeringBubble } from './MessageItem.tsx' +import type { ChatSnapshot } from '../contract/snapshot.ts' +import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' @@ -96,6 +97,33 @@ function isFolderOpenPath(path: string): boolean { return path === '.' } +/** + * Prompt-RPC identities already rendered by durable material: user/steering + * node sources plus queue occurrences. A submission echo whose identity + * appears here is hidden in the same render, so the echo→durable swap is + * atomic — no duplicate, no gap — regardless of when the echo leaves the + * session snapshot. + */ +function observedRpcIds( + order: readonly string[], + nodes: ChatSnapshot['nodes'], + queue: readonly { readonly rpcId?: string }[], +): ReadonlySet { + const observed = new Set() + for (const key of order) { + const node = nodes.get(key) + if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue + const source = (node.data as { readonly source?: unknown }).source as + | { readonly kind?: unknown; readonly rpcId?: unknown } + | undefined + if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + } + for (const item of queue) { + if (item.rpcId !== undefined) observed.add(item.rpcId) + } + return observed +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -202,6 +230,15 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + // Submission echoes still awaiting their durable counterpart. `order` is the + // recompute trigger: durable user material always arrives as an append, and + // every append replaces the order array. + const visibleSubmissions = useMemo(() => { + if (pendingSubmissions.length === 0) return pendingSubmissions + const observed = observedRpcIds(order, nodeStore, inbox) + return pendingSubmissions.filter(submission => !observed.has(submission.requestId)) + }, [pendingSubmissions, order, nodeStore, inbox]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], @@ -221,6 +258,7 @@ export function ChatView({ const openedRef = useRef(false) const lastKeyRef = useRef(null) const lastSteeringIdRef = useRef(null) + const lastSubmissionIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (which would snap inertial * scrolls the rest of the way to the floor). */ @@ -231,7 +269,8 @@ export function ChatView({ const lastKey = order.at(-1) ?? null const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` + const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` const toBottom = (el: HTMLElement): void => { anchorRef.current = null @@ -270,6 +309,7 @@ export function ChatView({ firstSeqRef.current = firstSeq lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -286,6 +326,7 @@ export function ChatView({ /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -294,13 +335,15 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current + const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -451,6 +494,14 @@ export function ChatView({ t={t} /> ))} + {visibleSubmissions.map(submission => ( + + ))} {!atBottom && (
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index c7368dc24d..6a3e26ff69 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,5 +1,7 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' +import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' @@ -214,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], t, + content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -224,9 +226,12 @@ function UserStyleBubble({ pending?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Local submission-echo previews replacing the content-derived image group. */ + previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images, rest } = contentParts(content) + const { text, images: contentImages, rest } = contentParts(content) + const images = previewImages ?? contentImages const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( @@ -277,6 +282,53 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: { ) } +/** + * Render one local submission echo with the exact visual language of the + * durable user node that replaces it: draft text plus object-URL previews, + * visible from the submit click until the durable `user/message` (or its + * queue occurrence) renders. + * @param props - the session snapshot's pending submission and render seats. + * @returns the echoed user bubble. + */ +export function PendingSubmissionBubble({ submission, renderMessageImages, t }: { + submission: PendingSubmission + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const content = useMemo( + () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), + [submission.text], + ) + const previewImages = useMemo( + () => submission.images.map(image => ({ + preview: { + url: image.previewUrl, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.width === undefined ? {} : { width: image.width }), + ...(image.height === undefined ? {} : { height: image.height }), + }, + })), + [submission.images], + ) + return ( + ( + + )} + /> + ) +} + /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ node, renderMessageImages, t, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1e9d71a523..094eeea06e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial = {}): SessionSnaps return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d4e4022e1c..cc1cf29745 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index bb5d339f0a..ffbde6bb29 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { calls.push(owner) return (
- {owner.images.map(({ attachment: image }, index) => ( - {image.name} - ))} + {owner.images.map((entry, index) => { + if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references') + const image = entry.attachment + return {image.name} + })}
) } From 5657066b1d76f6a8ac509aff82ae8bd7219081f0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:51:35 +0800 Subject: [PATCH 007/130] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E5=A5=91=E7=BA=A6=E6=89=A9=E6=95=A3=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=8C=96=20fake=20=E4=B8=8E=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/session-pending-submissions.client.spec.ts | 4 ++-- .../tests/conversation-registry.client.spec.ts | 1 + .../client/ui-conversation/tests/queue-dock.client.spec.tsx | 1 + packages/client/ui-trajectory/tests/table.client.spec.tsx | 5 ++++- .../tests/plan-review-panel.client.spec.tsx | 1 + 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index fa0e69d89a..0ab7073c3f 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -99,7 +99,7 @@ describe('beginSubmission', () => { describe('prompt-coupled retirement', () => { it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ text: '失败的', @@ -122,7 +122,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) session.beginSubmission({ text: '还在', images: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index f75e04523e..9ef8c333dd 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -44,6 +44,7 @@ function fakeSession(): SessionFace { projections: { faceOf: () => createSnapshotStore(undefined) }, getSnapshot: () => snapshot.getSnapshot(), subscribe: listener => snapshot.subscribe(listener), + beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }), prompt: () => Promise.reject(new Error('unused fake Session operation')), readAttachment: () => Promise.reject(new Error('unused fake Session operation')), updateQueue: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 6a30ed4d8a..3cd61a0b5b 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -39,6 +39,7 @@ function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { return { sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, + pendingSubmissions: [], lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, } } diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 9bd8de74f0..89a8165a29 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -13,7 +13,10 @@ import { t, tZh } from './locale.client.ts' const renderImagesStub: RenderMessageImages = ({ images }) => (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4faba04c00..c6f4180ed5 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -27,6 +27,7 @@ type AttentionState = Parameters Date: Wed, 26 Aug 2026 12:01:27 +0800 Subject: [PATCH 008/130] =?UTF-8?q?test+docs:=20=E5=9B=9E=E6=98=BE?= =?UTF-8?q?=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81=E5=8E=BB=E9=87=8D?= =?UTF-8?q?=E4=B8=8E=E9=A2=84=E8=A7=88=E7=A7=BB=E4=BA=A4=E7=9A=84=E8=A6=86?= =?UTF-8?q?=E7=9B=96=EF=BC=8CREADME=20=E4=B8=8E=20Agent=20Note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 sendSession 回显编排、ChatView 回显渲染与 rpcId 去重、control 队列 rpcId 投影、HistoricalImageCache.seed、MessageImage 预览 arm 的测试;四个包 README 双语更新;Agent Note 记录 rpcId 关联与延帧退休决策。 --- ...26-08-26-local-submission-echoes.i18n.yaml | 6 + .../2026-08-26-local-submission-echoes.md | 37 ++++ .../2026-08-26-local-submission-echoes.zh.md | 37 ++++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../tests/control-queue.host.spec.ts | 24 +++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../tests/message-image.client.spec.tsx | 39 ++++ packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 92 +++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../tests/historical-images.client.spec.ts | 37 ++++ .../service-orchestration.client.spec.ts | 177 ++++++++++++++++++ 20 files changed, 469 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md create mode 100644 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml new file mode 100644 index 0000000000..7a3d710fe7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.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-26-local-submission-echoes.md +2026-08-26-local-submission-echoes.md: 2151ebeb44f096e343aba88133495fbc1e743eb4 +2026-08-26-local-submission-echoes.zh.md: 052ca219164724d2bf687c3c728a7ea1581401cf diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md new file mode 100644 index 0000000000..2151ebeb44 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md @@ -0,0 +1,37 @@ +# Agent Note: Local submission echoes over the prompt rpcId + +Status: implemented + +English | [中文](2026-08-26-local-submission-echoes.zh.md) + +## Problem + +A multi-image prompt spent seconds in client serialization plus host admission before its durable `user/message` existed, and the conversation showed nothing until then: the composer froze read-only, the message appeared only after the full pipeline, and the user could not tell whether the submission had started (#3003). The durable event cannot move earlier — Model-visible ⟺ logged requires the `user/message` to land only after every attachment persists — so the visible submission had to decouple from the durable one. + +## Decision + +**The Session object owns a client-local submission echo, correlated by the prompt's existing `requestId`/`rpcId`.** `session.beginSubmission` synchronously inserts `{requestId, text, images: previews}` into `SessionSnapshot.pendingSubmissions` and flips `promptAttempted`, before the caller serializes anything; the same `requestId` rides the prompt RPC. No new correlation id, no wire-type change, and no session-log change: the host already stamps the prompt's `requestId` into the durable user source as `rpcId`, and the queue projection now carries it as `SessionQueuedItem.rpcId` for prompts that land in the inbox instead of the log (running-turn submissions). + +**Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins. + +**The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). A failed settlement restores the sent draft, occurrences, and image ids only into a still-empty plain composer — content typed during the flight always wins. Draft images stay registered until the echo retires: failed → available for rail restore; observed → each hands its object URL to `HistoricalImageCache.seed` under the admitted reference (URL ownership and scope-bound revocation move to the cache) so the durable node renders without a byte round-trip or loading flash. + +Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here. + +## Consequences + +The submit click paints its message and docks the composer on the same frame, for text and image prompts alike, while admission timing is unchanged. The composer never freezes for default sends, so drafts can be typed and sent during a flight; the machine's `submitting` phase now occurs only for command submissions. A prompt whose RPC response is lost but whose admission succeeded converges through observation instead of double-posting. Echo previews pin the original image blobs until the durable bytes would be fetched anyway; seeded cache entries keep the original (not the normalized) rendition for the session scope's lifetime, which trades some memory for zero-flash replacement. + +## Verification + +Session client specs pin synchronous insertion, requestId threading, event/queue/window observation, frame-delayed removal, first-settlement-wins, abandon, and disposal. Machine and shell specs pin the optimistic commit, detached settlement, untouched-composer restore, and image-only rail restore. ChatView specs pin flow-tail rendering, node- and queue-keyed dedupe with the echo still in the snapshot, and preview handoff through the message-image slot. Host control specs pin the queue rpcId projection; cache specs pin seed adoption, exclusivity, and scope revocation. The connection fixture echoes `requestId`, so assembled web replays exercise the same retirement. + +## Alternatives considered + +**A new `clientSubmissionId` threaded through the wire and the user source.** Rejected: `requestId` already exists end-to-end (`user-rpc` source member), so a second id would duplicate the correlation and touch wire validation for nothing. + +**Retire the echo synchronously on event ingestion.** Rejected: the chat assembly publishes on an animation frame, so synchronous removal blanks the message for a frame. The steering queue mirror historically accepted that race; the echo path removes it via render-time dedupe plus the delayed retirement. + +**Render echoes through the conversation assembler as synthetic nodes.** Rejected: the assembler is driven by durable session events only, and a client-only node kind would widen the closed `ConversationNode` union into every target's `assertNever`; the `PartialAssistant`-style side-channel state matches the existing precedent. + +**Keep the composer frozen and only add the echo.** Rejected: the issue's acceptance requires consecutive and concurrent submissions, and a frozen composer reintroduces the perceived hang the echo exists to remove. diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md new file mode 100644 index 0000000000..052ca21916 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md @@ -0,0 +1,37 @@ +# Agent Note:基于 prompt rpcId 的本地提交回显 + +状态:implemented + +[English](2026-08-26-local-submission-echoes.md) | 中文 + +## 问题 + +多图 prompt 在客户端序列化加 host admission 上要花数秒,durable `user/message` 在此之前不存在,会话在此期间什么都不显示:composer 冻结为只读,消息在整条流水线结束后才出现,用户无法判断提交是否已经开始(#3003)。durable event 无法提前,Model-visible ⟺ logged 要求 `user/message` 只能在全部附件持久化后落盘,因此可见的提交必须与 durable 的提交解耦。 + +## 决定 + +**Session 对象持有客户端本地的提交回显,用 prompt 现有的 `requestId`/`rpcId` 关联。**`session.beginSubmission` 在调用方序列化任何内容之前,同步把 `{requestId, text, images: previews}` 写入 `SessionSnapshot.pendingSubmissions` 并翻转 `promptAttempted`;同一个 `requestId` 随 prompt RPC 发出。没有新关联 id,没有 wire 类型改动,也没有 session log 改动:host 本就把 prompt 的 `requestId` 写进 durable user source 的 `rpcId`,queue 投影现在把它作为 `SessionQueuedItem.rpcId` 携带,覆盖落进 inbox 而非 log 的 prompt(运行中 turn 的提交)。 + +**退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。 + +**Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行(允许并发发送,唯一的冻结 in-flight 槽只留给命令)。失败的 settlement 只把已发送的草稿、occurrence 和图片 id 还原进仍为空的 plain composer,飞行期间输入的内容始终优先。草稿图片保持注册直到回显退休:failed 时可供 rail 还原;observed 时逐张把 object URL 通过 `HistoricalImageCache.seed` 挂到 admitted 引用名下(URL 所有权与随 scope 的回收移交缓存),durable 节点因此无需字节往返即可渲染,没有加载闪烁。 + +客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。 + +## 后果 + +点击提交在当帧画出消息并让 composer 落底,文本与图片 prompt 一致,admission 时机不变。默认发送不再冻结 composer,飞行期间可以继续输入和发送;machine 的 `submitting` 阶段只在命令提交时出现。RPC 响应丢失但 admission 已成功的 prompt 通过观察收敛,不会重复发送。回显预览会固定原始图片 blob,直到 durable 字节本来也要被拉取为止;seed 进缓存的条目在 session scope 生命周期内保留原图而非归一化版本,用一些内存换零闪烁替换。 + +## 验证 + +Session client spec 钉住同步插入、requestId 透传、event/queue/窗口观察、延帧移除、先到 settlement 生效、abandon 与销毁。Machine 与 shell spec 钉住乐观提交、detached settlement、未触碰 composer 的还原和图片纯发送的 rail 还原。ChatView spec 钉住流尾渲染、回显仍在 snapshot 时按节点与队列的去重,以及经 message-image slot 的预览移交。Host control spec 钉住 queue rpcId 投影;缓存 spec 钉住 seed 的接管、排他与 scope 回收。connection fixture 回显 `requestId`,组装 web 回放具备同样的退休语义。 + +## 考虑过的替代方案 + +**新增 `clientSubmissionId` 贯穿 wire 与 user source。**否决:`requestId` 已端到端存在(`user-rpc` source 成员),第二个 id 会重复关联并平白触碰 wire 校验。 + +**事件入库时同步移除回显。**否决:会话组装按动画帧发布,同步移除会让消息空一帧。steering 队列镜像历史上接受了这个竞态;回显路径用渲染期去重加延帧退休消除它。 + +**把回显作为合成节点走会话 assembler。**否决:assembler 只由 durable session event 驱动,客户端专属的节点 kind 会把闭合的 `ConversationNode` 联合扩进每个 target 的 `assertNever`;`PartialAssistant` 式的旁路状态符合现有先例。 + +**保持 composer 冻结,只加回显。**否决:issue 验收要求连续与并发提交,冻结的 composer 会重新引入回显本要消除的卡顿感。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3325cc86a4..4f66278d0d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: 815276847f538f99352e0f0e55fc19af5da67470 -README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b +README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0 +README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 815276847f..dc04e595c9 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 51bf5a98c6..b34c8ad018 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。 + ----- diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 9ce0c453a0..e3fe868c89 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -69,6 +69,30 @@ describe('Session control queue projection', () => { await iterator.next() }) + it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => { + const { control, inbox } = await harness() + const identified = createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'req-42' as never }, + }) + inbox.append('next-turn', identified) + inbox.append('next-step', message('plain steering')) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') + const items = opened.value.value.queues['queue-session' as SessionId] ?? [] + expect(items).toMatchObject([ + { id: identified.id, placement: 'queued', rpcId: 'req-42' }, + { id: expect.anything(), placement: 'steering' }, + ]) + expect('rpcId' in (items[1] ?? {})).toBe(false) + + abort.abort() + await iterator.next() + }) + it('ignores inbox events without the exact live Agent session', async () => { const { ctx, control, agent, inbox } = await harness() const abort = new AbortController() diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 7378631ca9..5e0e50e06a 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/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-attachment/README.md -README.md: 3e9240f5f2f68dbc9f709ee7e720bb48053ba3b5 -README.zh.md: 0a450d2bc78f183847b945c73107c1466fb3ba0e +README.md: 9fa03432b43686dd55af494640708a8bf0983f9a +README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3e9240f5f2..9fa03432b4 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -54,7 +54,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal | diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 0a450d2bc7..48e467280b 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开 | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖拽邀请 portal | diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 5972beacdb..64d1eea37e 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -152,6 +152,45 @@ describe('MessageImage', () => { }) }) +describe('MessageImage preview arm', () => { + it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { + const load = vi.fn() + const view = render( + , + ) + expect(load).not.toHaveBeenCalled() + const img = view.getByAltText('echo.png') as HTMLImageElement + expect(img.src).toContain('blob:echo') + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('120px') + }) + + it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { + const load = vi.fn() + const view = render( + , + ) + const img = view.getByAltText('图片') as HTMLImageElement + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('240px') + }) + + it('opens the lightbox from a preview thumbnail', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + }) +}) + describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index dbcc134300..9146293925 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: b44c061aa9af8f58dbbae212c72fa55e186e4564 -README.zh.md: b339fea3835f0be0e6e07ec93577c4f11c80c1b7 +README.md: a48413b7d6ef95d23ff854db125cb2757afd93da +README.zh.md: e64d55f754392867820fd9b4d8fa2593aa84fc23 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b44c061aa9..a48413b7d6 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index b339fea383..e64d55f754 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。 ## 目录 diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 094eeea06e..a0720a90fa 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -661,6 +661,98 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1) }) + it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + pendingSubmissions: [ + { requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('即发即显')).toBeTruthy() + + // The durable node arrives while the echo is STILL in the session + // snapshot: the render-time rpcId dedupe keeps exactly one bubble. + act(() => { + h.setChat({ + nodes: [ + assistant(1, 'working'), + { + kind: 'user', seq: 2, time: 2_000, + content: [{ type: 'text', text: '即发即显' }] as never, + source: { kind: 'user', rpcId: 'req-1' } as never, + }, + ], + }) + }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + + // The delayed snapshot retirement changes nothing visible. + act(() => { h.setSession({ pendingSubmissions: [] }) }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + }) + + it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + running: true, + pendingSubmissions: [ + { requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('排队中')).toBeTruthy() + act(() => { + h.setSession({ + queue: [{ + id: 'q-occurrence' as never, + messageId: 'q-message' as never, + placement: 'queued' as const, + rpcId: 'req-q' as never, + content: [{ type: 'text' as const, text: '排队中' }], + preview: '排队中', + text: '排队中', + }], + }) + }) + // The queued occurrence renders in the queue dock, not the flow; the + // flow-tail echo yields to it in the same snapshot. + expect(view.queryByText('排队中')).toBeNull() + }) + + it('an image echo renders its previews through the message-image slot', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-img' as never, + time: 7_000, + text: '', + images: [ + { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + { previewUrl: 'blob:echo-b' }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const images = (owner as { images: readonly unknown[] }).images + return
+ }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const gallery = view.getByTestId('echo-images') + expect(gallery.getAttribute('data-count')).toBe('2') + expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }) + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 122fcc3d5d..bf29a9bd0f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 276f4126328ab26f610e8a59bc18e43283290296 -README.zh.md: 3b3e2135625e521bc4362de657d259222cb3d09b +README.md: f0669403f90d2283a0ae521b569955978cdaf94a +README.zh.md: 9341772e1148880a3caa4fa9443fa8a2cf7e9880 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 276f412632..f0669403f9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,6 +40,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. A failed send restores the sent draft, references, and image ids only into a still-untouched empty composer; command submissions keep the frozen `submitting` phase. When an echo retires as observed, its draft previews hand their object URLs to the durable image cache (`seedImageUrl`) so the transcript node displays without a byte round-trip. + While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3b3e213562..9341772e11 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,6 +40,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,飞行期间可以继续输入和继续发送。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。发送失败只把已发送的草稿、引用和图片 id 还原进仍未被触碰的空 composer;命令提交保持冻结的 `submitting` 阶段。回显以 observed 退休时,草稿预览把 object URL 移交 durable 图片缓存(`seedImageUrl`),transcript 节点无需字节往返即可显示。 + 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 71aa3d6018..7b44cd2016 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -25,4 +25,41 @@ describe('HistoricalImageCache', () => { await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) + + it('adopts a seeded URL, reuses it for later resolves, and revokes it with the Session scope', async () => { + const revoked: string[] = [] + const originalRevoke = URL.revokeObjectURL + URL.revokeObjectURL = (url: string) => { revoked.push(url) } + try { + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ id: 's1', session: {} }) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) + // Ownership is exclusive: a second seed of the same reference refuses, + // and resolve() serves the adopted URL without a byte round-trip. + expect(cache.seed(sessionId, attachment, 'blob:duplicate')).toBe(false) + await expect(cache.resolve(sessionId, attachment)).resolves.toBe('blob:seeded') + + await runtime.sessions.remove(sessionId) + await Promise.resolve() + expect(revoked).toContain('blob:seeded') + await runtime.dispose() + } finally { + URL.revokeObjectURL = originalRevoke + } + }) + + it('refuses to seed for an unknown session', async () => { + const runtime = await SlotTestRuntime.create() + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-unknown'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + expect(cache.seed('missing' as never, attachment, 'blob:orphan')).toBe(false) + await runtime.dispose() + }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 4633d07e20..8950cf5945 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -131,6 +131,183 @@ describe('ConversationController', () => { }) }) +describe('sendSession submission echo', () => { + /** Bench with an observable beginSubmission on the session face. */ + async function echoBench() { + const b = await bench() + const retire: { onRetire?: (retirement: unknown) => void } = {} + const abandon = vi.fn() + const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + retire.onRetire = input.onRetire + return { requestId: 'req-echo' as never, abandon } + }) + await b.runtime.sessions.updateSessionSnapshot('s1', () => {}) + const face = b.runtime.sessions.binding('s1')!.session as unknown as Record + face['beginSubmission'] = beginSubmission + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:echo-1') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + const restore = () => { + created.mockRestore() + revoked.mockRestore() + } + return { ...b, beginSubmission, abandon, retire, revoked, restore } + } + + it('registers the echo before serialization and prompts with its identity', async () => { + const b = await echoBench() + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') + // Synchronous: the echo is registered before any encoding starts. + expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ + text: '带图', + images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], + })) + expect(b.prompt).not.toHaveBeenCalled() + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith( + [ + { type: 'image', mediaType: 'image/png', data: expect.any(String), name: 'a.png' }, + { type: 'text', text: '带图' }, + ], + 'queue', + undefined, + 'req-echo', + ) + // The draft stays registered until the echo's observed retirement. + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + b.retire.onRetire?.({ reason: 'observed', attachments: [] }) + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => { + const b = await echoBench() + try { + const seedImageUrl = vi.fn(() => true) + b.runtime.ctx.provide('uiConversation') + b.runtime.ctx.set('uiConversation', { seedImageUrl }) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await b.root.sendSession(session, '', [attachment!.id], 'queue') + const ref = { attachmentId: 'att-1' } + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).not.toHaveBeenCalled() + // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + const b = await echoBench() + try { + b.prompt.mockResolvedValueOnce({ + ok: false, error: { code: 'attachment-error', message: 'nope', details: {} }, + } as never) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + .resolves.toEqual({ kind: 'error' }) + b.retire.onRetire?.({ reason: 'failed' }) + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.revoked).not.toHaveBeenCalled() + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('abandons the echo when encoding fails before the prompt', async () => { + const b = await echoBench() + class FailingReader { + onload: (() => void) | null = null + onerror: (() => void) | null = null + error = new Error('read failed') + readAsDataURL(): void { + queueMicrotask(() => this.onerror?.()) + } + } + vi.stubGlobal('FileReader', FailingReader) + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue')) + .rejects.toThrow('read failed') + expect(b.abandon).toHaveBeenCalledOnce() + expect(b.prompt).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('yields through the macrotask fallback where no frame clock exists', async () => { + const b = await echoBench() + vi.stubGlobal('requestAnimationFrame', undefined) + try { + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '纯文本', [], 'queue')).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '纯文本' }], 'queue', undefined, 'req-echo') + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) +}) + +describe('draft image dimension probe', () => { + it('fills intrinsic dimensions from the header probe and skips runtimes without Image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:probe') + class InstantImage { + onload: (() => void) | null = null + naturalWidth = 0 + naturalHeight = 0 + set src(_value: string) { + this.naturalWidth = 640 + this.naturalHeight = 480 + this.onload?.() + } + } + vi.stubGlobal('Image', InstantImage) + try { + const [probed] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), + ]) + expect(probed).toMatchObject({ width: 640, height: 480 }) + vi.stubGlobal('Image', undefined) + const [unprobed] = b.root.createDraftImages([ + new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), + ]) + expect(unprobed?.width).toBeUndefined() + } finally { + vi.unstubAllGlobals() + created.mockRestore() + } + await b.runtime.dispose() + }) +}) + describe('InputHub queue steering (empty-draft accelerated Enter)', () => { const row = (id: string): QueuedMessage => ({ id: id as never, From f1606e31d247535498f8c89b6369fb32589371b7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 12:12:08 +0800 Subject: [PATCH 009/130] =?UTF-8?q?test(web):=20=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E6=98=BE=E7=9A=84=E7=BB=84=E8=A3=85=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=20e2e=20=E4=B8=8E=E4=B8=8D=E5=8F=AF=E8=A7=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingSubmissionBubble 携带 data-submission-echo 标记(渲染不变,仅供检测), 新增 keyless 组装 e2e:发送按键当下回显即在流中、composer 已清空可编辑, durable 节点到达后原位替换且只剩一条气泡。 --- apps/web/tests/submission-echo.e2e.ts | 65 +++++++++++++++++++ .../tests/control-queue.host.spec.ts | 4 +- ...session-pending-submissions.client.spec.ts | 3 +- .../ui-chat/src/client/chat/MessageItem.tsx | 12 +++- .../ui-chat/tests/chat-view.client.spec.tsx | 5 +- .../tests/historical-images.client.spec.ts | 2 +- .../tests/input-machine.client.spec.ts | 32 +++++++++ .../service-orchestration.client.spec.ts | 2 +- 8 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/submission-echo.e2e.ts diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts new file mode 100644 index 0000000000..42f04e730d --- /dev/null +++ b/apps/web/tests/submission-echo.e2e.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +// Local submission echo over the BUILT client graph (keyless FixtureApiClient +// transport): a text-plus-image send paints its echo bubble synchronously on +// the submit keystroke — before serialization, transport, or the fixture's +// durable admission — with the composer already cleared and editable, and the +// durable user/message replaces the echo without a duplicate. The fixture host +// echoes the prompt requestId as the durable source's rpcId, so the retirement +// path here is the production correlation, not a test hook. +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + throw new Error('attachment rail missing') + } + }, { timeout: 5_000 }) + fireEvent.change(textarea, { target: { value: '回显这条消息' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + // Synchronously after the keystroke: the echo bubble is in the flow with + // the draft text and the object-URL preview, while the prompt has not even + // been serialized yet (it starts after a paint yield). The composer is + // already cleared, editable, and free of the rail. + const echo = document.querySelector('[data-submission-echo]') + if (echo === null) throw new Error('submission echo missing on the send keystroke') + expect(echo.textContent).toContain('回显这条消息') + expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') + expect((textarea as HTMLTextAreaElement).value).toBe('') + expect((textarea as HTMLTextAreaElement).readOnly).toBe(false) + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + + // The fixture's durable user/message (source.rpcId echoes the prompt + // requestId) replaces the echo: one bubble, no marker left, and the image + // now renders from the durable gallery. + await waitFor(() => { + if (document.querySelector('[data-submission-echo]') !== null) { + throw new Error('submission echo still present after the durable node arrived') + } + }, { timeout: 10_000 }) + expect(screen.getAllByText('回显这条消息')).toHaveLength(1) + await waitFor(() => { + if (document.querySelector('[data-align="end"] img') === null) { + throw new Error('durable user gallery missing') + } + }, { timeout: 10_000 }) +}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index e3fe868c89..b4645dc2f3 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -83,9 +83,9 @@ describe('Session control queue projection', () => { const opened = await iterator.next() if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') const items = opened.value.value.queues['queue-session' as SessionId] ?? [] - expect(items).toMatchObject([ + expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([ { id: identified.id, placement: 'queued', rpcId: 'req-42' }, - { id: expect.anything(), placement: 'steering' }, + { id: items[1]?.id, placement: 'steering', rpcId: undefined }, ]) expect('rpcId' in (items[1] ?? {})).toBe(false) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 0ab7073c3f..76b58abf79 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import { Session } from '../src/client/sessions/session.ts' @@ -43,7 +42,7 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA ...refs.map(attachment => ({ type: 'image' as const, attachment })), { type: 'text' as const, text: '发送' }, ], - source: { kind: 'user', rpcId } as MessageSource, + source: { kind: 'user', rpcId }, }), } as unknown as SessionEvent } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 6a3e26ff69..58af540f70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -216,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -224,6 +224,8 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */ + echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ @@ -235,7 +237,12 @@ function UserStyleBubble({ const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( -
+
{renderMessageImages({ images, align: 'end' })} {showBubble &&
@@ -315,6 +322,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: content={content} previewImages={previewImages} renderMessageImages={renderMessageImages} + echo t={t} actions={text => ( { }, ) const view = render() - expect(view.getByText('即发即显')).toBeTruthy() + expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull() // The durable node arrives while the echo is STILL in the session // snapshot: the render-time rpcId dedupe keeps exactly one bubble. @@ -682,12 +682,13 @@ describe('ChatView', () => { { kind: 'user', seq: 2, time: 2_000, content: [{ type: 'text', text: '即发即显' }] as never, - source: { kind: 'user', rpcId: 'req-1' } as never, + source: { kind: 'user', rpcId: 'req-1' }, }, ], }) }) expect(view.getAllByText('即发即显')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() // The delayed snapshot retirement changes nothing visible. act(() => { h.setSession({ pendingSubmissions: [] }) }) diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 7b44cd2016..a8f777d12c 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -28,7 +28,7 @@ describe('HistoricalImageCache', () => { it('adopts a seeded URL, reuses it for later resolves, and revokes it with the Session scope', async () => { const revoked: string[] = [] - const originalRevoke = URL.revokeObjectURL + const originalRevoke = URL.revokeObjectURL.bind(URL) URL.revokeObjectURL = (url: string) => { revoked.push(url) } try { const runtime = await SlotTestRuntime.create() diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index c9938c6c10..0b601c4ff2 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -550,6 +550,38 @@ describe('input-machine: undo / redo', () => { expect(n.state.draft).toBe('typed during flight') }) + it('runs concurrent detached sends and settles them independently in any order', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: '第一条' }) + const first = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: '第二条' }) + const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(m.state.phase).toBe('plain') + expect(m.state.draft).toBe('') + expect(second.attempt.seq).toBeGreaterThan(first.attempt.seq) + // Later attempt fails first: its draft restores into the empty composer. + m.dispatch({ type: 'sink-settled', attempt: second.attempt, ok: false, message: 'boom' }) + expect(m.state.draft).toBe('第二条') + // The earlier failure then finds a non-empty composer and must not clobber it. + m.dispatch({ type: 'sink-settled', attempt: first.attempt, ok: false, message: 'boom' }) + expect(m.state.draft).toBe('第二条') + // Release aborts nothing further: both settlements already consumed their records. + expect(m.dispatch({ type: 'release' })).toEqual([]) + }) + + it('release aborts every in-flight detached send', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'A' }) + const first = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: 'B' }) + const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'release' }) + expect(first.attempt.signal.aborted).toBe(true) + expect(second.attempt.signal.aborted).toBe(true) + // Settlements after release are dropped stale events. + expect(m.dispatch({ type: 'sink-settled', attempt: first.attempt, ok: false, message: 'late' })).toEqual([]) + }) + it('a failed detached flight restores the sent draft and occurrences into an untouched composer', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'restore me' }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 8950cf5945..39ced8ab36 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -170,7 +170,7 @@ describe('sendSession submission echo', () => { await expect(sending).resolves.toEqual({ kind: 'success' }) expect(b.prompt).toHaveBeenCalledWith( [ - { type: 'image', mediaType: 'image/png', data: expect.any(String), name: 'a.png' }, + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'a.png' }, { type: 'text', text: '带图' }, ], 'queue', From c01cf6e54972289f1c6f1cf22bdc4c3dcef98d03 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 12:12:59 +0800 Subject: [PATCH 010/130] =?UTF-8?q?test:=20exactOptionalPropertyTypes=20?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=20onRetire=20=E6=8D=95=E8=8E=B7=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui-conversation/tests/service-orchestration.client.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 39ced8ab36..cfca122d19 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -135,7 +135,7 @@ describe('sendSession submission echo', () => { /** Bench with an observable beginSubmission on the session face. */ async function echoBench() { const b = await bench() - const retire: { onRetire?: (retirement: unknown) => void } = {} + const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} const abandon = vi.fn() const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { retire.onRetire = input.onRetire From 2dd59b2ca191ee7012c12aedf3d8405c082c3e20 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 13:49:20 +0800 Subject: [PATCH 011/130] test(client): cover instant image echo branches --- .../ui-attachment/tests/message-image.client.spec.tsx | 8 +++++++- .../client/ui-chat/tests/apply-inject.client.spec.tsx | 4 +++- .../client-runtime/tests/runtime.client.spec.tsx | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d9c3008246..76a720b983 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -207,10 +207,16 @@ describe('ImageGallery', () => { const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - , + , ) expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + expect(view.getByAltText('echo.png')).toBeTruthy() }) it('renders a lone image large and several images as square tiles', () => { diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 0fd861a14a..328868a2dc 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -169,8 +169,10 @@ describe('Chat inject API', () => { injected.chatScroll.save(null) expect(injected.chatScroll.read()).toBeNull() - await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String)) + const loaded = await injected.loadImage(ATTACHMENT) + expect(loaded).toEqual(expect.any(String)) expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded) await b.runtime.dispose() }) }) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index 02d239b117..5216f778c2 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -433,6 +433,9 @@ describe('fixture session face', () => { expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) expect(() => bare.rename()).toThrow(/rename is not stubbed/) + const submission = bare.beginSubmission() + expect(submission.requestId).toBe('test-submission-1') + expect(() => { submission.abandon() }).not.toThrow() await runtime.dispose() }) From 7817ed3d82aec1b5940d6d9ea64f9b7292c41121 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 14:09:31 +0800 Subject: [PATCH 012/130] docs: refresh Claude SDK notices --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d47cdd7d2c..08c7f13f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies From dc825114979d64cde3b2628267f619f8161bae3d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 14:49:29 +0800 Subject: [PATCH 013/130] ci(windows): restore complete coverage sharding --- .github/workflows/ci.yml | 2 +- scripts/ci-workflow.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 182d66d6fc..20fa5fc99c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,7 +452,7 @@ jobs: timeout-minutes: 120 env: DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '6' + DSH_COVERAGE_PARTITIONS: '4' DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' DSH_GATE_CONCURRENCY: '3' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 084e30c754..499d5a38d7 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -98,9 +98,9 @@ describe('CI workflow', () => { )) expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking') - // windows-coverage runs the 6-partition profile. + // windows-coverage runs the 4-partition profile. expect(windowsCoverage.name).toBe('windows node 24 / coverage') - expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '6' }) + expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' }) const coverageSteps = windowsCoverage.steps as unknown[] const coverageCommands = coverageSteps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' From e87a47692d01ca4a8c7793bd6321848602bfd175 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 13:12:53 +0800 Subject: [PATCH 014/130] ci: isolate the Windows pnpm setup destination per job The windows-* jobs keep a separate standalone pnpm executable under runner.temp/setup-pnpm-js. A previous job on the same self-hosted runner can leave a locked @reflink native module there, so the next job's pnpm/action-setup fails with EPERM during unlink before any test runs. Suffix the destination with run_id, run_attempt, and job so every job gets a fresh directory even when sequential jobs land on the same runner; apply the same to the python SDK exe build. Update the pnpm setup isolation note to record the Windows-specific destination. --- ...7-29-pnpm-setup-runner-isolation.i18n.yaml | 4 ++-- .../2026-07-29-pnpm-setup-runner-isolation.md | 2 +- ...26-07-29-pnpm-setup-runner-isolation.zh.md | 2 +- .../workflows/build-exe-for-python-sdk.yml | 2 +- .github/workflows/ci.yml | 8 +++---- scripts/ci-workflow.spec.ts | 21 ++++++++++++++++++- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index 525c192406..68f963f3ea 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: c7c076f34dcd4b905a6bb54411538d6cf61bc1d0 -2026-07-29-pnpm-setup-runner-isolation.zh.md: 2dd866404a5a799fe33e8b9c70c17787dfed0c2a +2026-07-29-pnpm-setup-runner-isolation.md: 14a609c56eb45f706c7451d2659ab397c2886411 +2026-07-29-pnpm-setup-runner-isolation.zh.md: cdb13f9cfb7e2abf7d1563c39526ea2d9e1ca562 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index c7c076f34d..14a609c56e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,7 +10,7 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). [The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index 2dd866404a..cdb13f9cfb 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个 `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 [工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 0a3dc200f2..0cb0c1789e 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -161,7 +161,7 @@ jobs: - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - name: Enable Windows Developer Mode (symlink support) if: runner.os == 'Windows' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20fa5fc99c..74d4955fa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,7 +430,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -470,7 +470,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -508,7 +508,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} @@ -554,7 +554,7 @@ jobs: run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: - dest: ${{ runner.temp }}/setup-pnpm-js + dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} - uses: actions/setup-node@v6 with: node-version: ${{ env.PRIMARY_NODE_VERSION }} diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 40a665d667..7c0b762bdc 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' const root = resolve(import.meta.dirname, '..') const runnerPrivatePnpmDestination = '${{ runner.temp }}/setup-pnpm' -const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js' +const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}' describe('CI workflow', () => { it('isolates every pnpm action setup destination per runner', () => { @@ -36,6 +36,25 @@ describe('CI workflow', () => { } }) + it('isolates the python SDK exe pnpm setup destination per job', () => { + const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8')) + if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('build-exe-for-python-sdk.yml must define jobs') + const setups: Array<{ step: unknown }> = [] + for (const job of Object.values(workflow.jobs)) { + if (!isRecord(job) || !Array.isArray(job.steps)) continue + for (const step of job.steps) { + if (!isRecord(step) || typeof step.uses !== 'string' || !step.uses.startsWith('pnpm/action-setup@')) continue + setups.push({ step }) + } + } + expect(setups.length).toBeGreaterThan(0) + for (const { step } of setups) { + expect(step).toMatchObject({ + with: { dest: nativeWindowsPnpmDestination }, + }) + } + }) + it('keeps required Wine and split native Windows jobs with failover, plus a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml') From e9cb003e9e6c1b225c04abc2d72b930ec0aadffa Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 15:04:45 +0800 Subject: [PATCH 015/130] test: widen oxlint contract and built-bin spawn budgets Both suites spawn real subprocesses (oxlint probes; the dsh built bin) that cold-start slowly on the contended self-hosted Windows pool, so their 20-25s timeouts fire before the child finishes. Raise the oxlint contract case timeouts to 60s and the built-bin execa timeouts to 60s, matching the tool-ralph budget treatment. --- apps/cli/tests/built-bin.e2e.ts | 8 ++++---- scripts/oxlint-contract.spec.ts | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index ef175ac199..98008cf006 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -36,7 +36,7 @@ async function runBuiltBin( ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', reject: false, env: childEnv, @@ -310,7 +310,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { cwd: fixture.home, input: '', reject: false, - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', env: { DSH_HOME: fixture.home, @@ -423,7 +423,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], { cwd: home, reject: false, - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', env: { ...process.env, @@ -484,7 +484,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, - timeout: 25_000, + timeout: 60_000, killSignal: 'SIGKILL', env: { ...process.env, diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 847d0a7593..24fbbd4af0 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -105,7 +105,7 @@ probePromise() rm(configPath, { force: true }), ]) } - }, 20_000) + }, 60_000) it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() @@ -152,7 +152,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 20_000) + }, 60_000) it('keeps the complete stylistic contract in Oxlint', async () => { const oxlintPath = join(repositoryRoot, '.oxlintrc.json') @@ -252,7 +252,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 20_000) + }, 60_000) it('accepts an ignored-only staged selection', () => { const result = runOxlint([ @@ -371,6 +371,6 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + await rm(directory, { recursive: true, force: true }) } }, - 20_000, + 60_000, ) }) From c20cfe77c60e9277ad55fc9db8e93d0b3dcf37a2 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 16:13:46 +0800 Subject: [PATCH 016/130] test: align built-bin spawn budget with its outer case budgets The execa timeout was widened to 60s but the outer vitest case budgets stayed at 30s, so a cold-starting built bin would trip the vitest budget first and the execa SIGKILL cleanup could not run inside it. Extract SPAWN_TIMEOUT_MS, share it across the execa deadline, its error text, waitForFile, and the outer case budgets (60s spawn + 30s headroom), so the widening is coherent. --- apps/cli/tests/built-bin.e2e.ts | 56 ++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 26 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 98008cf006..c133320ace 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -19,6 +19,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +// The dsh built bin cold-starts slowly on the contended self-hosted Windows pool; the +// execa deadline, its error text, the outer vitest case budget, and waitForFile all +// share this value so a widening cannot leave a stale 25s diagnostic behind. +const SPAWN_TIMEOUT_MS = 60_000 // The release version, including a prerelease such as 0.0.1-rc.1: `--version` // prints what this manifest carries, so no test may pin it to a literal. const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version @@ -36,7 +40,7 @@ async function runBuiltBin( ) const result = await execa(process.execPath, [dshBin, ...args], { input: '', - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false, env: childEnv, @@ -44,13 +48,13 @@ async function runBuiltBin( ...cwd === undefined ? {} : { cwd }, }) if (result.timedOut) { - throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) + throw new Error(`dsh built bin did not exit within ${SPAWN_TIMEOUT_MS / 1_000}s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) } return { stdout: result.stdout, code: result.exitCode ?? -1, stderr: result.stderr } } async function waitForFile(file: string): Promise { - const deadline = Date.now() + 20_000 + const deadline = Date.now() + SPAWN_TIMEOUT_MS while (!existsSync(file)) { if (Date.now() >= deadline) throw new Error(`dsh profile lifecycle marker did not appear: ${file}`) await new Promise(resolve => setTimeout(resolve, 20)) @@ -310,7 +314,7 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { cwd: fixture.home, input: '', reject: false, - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { DSH_HOME: fixture.home, @@ -335,7 +339,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await runBuiltBin(removed) expect(result.code).toBe(1) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('routes help and usage errors without activating startup-dependent rows', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) @@ -416,14 +420,14 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('serves the SDK protocol through the sdk profile and exits after shutdown', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-')) const child = execa(process.execPath, [dshBin, '--profile', 'sdk'], { cwd: home, reject: false, - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { ...process.env, @@ -471,7 +475,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await child rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('runs a mock-backed ACP turn through the acp profile and exits on disconnect', async () => { const apiKey = 'built-acp-profile-key' @@ -484,7 +488,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const child = execa(process.execPath, [dshBin, '--profile', 'acp'], { cwd: home, reject: false, - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', env: { ...process.env, @@ -555,7 +559,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await server.close() rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('runs the headless profile through its app-owned task positional', async () => { const apiKey = 'built-dsh-headless-key' @@ -583,7 +587,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', await server.close() rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('does not load a project environment for --version', async () => { const project = mkdtempSync(join(tmpdir(), 'dsh-version-project-')) @@ -606,7 +610,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('uses the launching endpoint and managed credential through the published entry', async () => { const apiKey = 'built-home-layer-key' @@ -646,7 +650,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', rmSync(home, { recursive: true, force: true }) rmSync(project, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('reports a patch-overlay boot failure without hanging', async () => { // The HMR main watcher's initial scan once refreshed the include @@ -666,7 +670,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() @@ -682,7 +686,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('fully settles a custom profile, hot-reloads its patch layer with removal reverting, and disposes on a signal', async () => { const fixture = createProfileLifecycleFixture() @@ -734,7 +738,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('hands the app arguments to the profile, which applies them before its rows start', async () => { const fixture = createStartupFixture() @@ -750,7 +754,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { const fixture = createStartupFixture() @@ -764,7 +768,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('keeps the app arguments across a user patch reload', async () => { // A live edit recomposes every row while the provider service remains @@ -798,7 +802,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', child.kill('SIGKILL') rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it("prints the app's own help, starts none of its rows, and exits", async () => { const fixture = createStartupFixture() @@ -811,7 +815,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(fixture.home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT @@ -829,7 +833,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await execa(process.execPath, [dshBin, 'plugin', '--profile', 'anchor', 'add', '.'], { cwd: checkout, input: '', - timeout: 60_000, + timeout: SPAWN_TIMEOUT_MS, killSignal: 'SIGKILL', reject: false, env: { DSH_HOME: home }, @@ -897,7 +901,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) describe('config dump', () => { let home: string @@ -913,7 +917,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-base') expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false) - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('prints the headless profile without Host or browser layers', async () => { const { stdout, code, stderr } = await runBuiltBin( @@ -926,7 +930,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('prints the exact standalone sdk-minimal tree without dsh-base', async () => { const { stdout, code, stderr } = await runBuiltBin( @@ -959,7 +963,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. @@ -998,6 +1002,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') - }, 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) }) }) From 43b5b473bf09bd1bf10195a72966a1fdfff18b4d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 16:16:37 +0800 Subject: [PATCH 017/130] ci: drop the stale pnpm setup cleanup steps The windows-* jobs now install pnpm under a run/attempt/job-suffixed destination, so the pre-install step that cleared the old fixed setup-pnpm-js path no longer touches the actual destination and its comment claims stale state. The suffix already gives every job a fresh directory, so remove the four cleanup steps. --- .github/workflows/ci.yml | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74d4955fa5..7d9b3e5925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,13 +421,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort: a torn-down job on this self-hosted pool can leave a - # locked @reflink native module under the action's install destination, - # and pnpm/action-setup's self-installer then fails its unlink with - # EPERM. Clearing the destination gives every attempt fresh state. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -464,10 +457,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -502,10 +491,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} @@ -548,10 +533,6 @@ jobs: run: >- reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock" /t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1" - # Best-effort stale pnpm-destination cleanup; rationale on windows-build's copy. - - name: Clear stale pnpm setup state - shell: pwsh - run: if (Test-Path "$env:RUNNER_TEMP/setup-pnpm-js") { Remove-Item -Recurse -Force "$env:RUNNER_TEMP/setup-pnpm-js" -ErrorAction SilentlyContinue } - uses: pnpm/action-setup@v4 with: dest: ${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} From 84692044af90811536380a8be16c00d0e99624c0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 17:17:47 +0800 Subject: [PATCH 018/130] test: raise the contended Windows spawn budgets to 90s The per-case 15-30s budgets on the Windows native and coverage lanes fire before oxlint, workflow-worker-thread, and other subprocess-spawning cases finish under the loaded self-hosted pool; the failures rotate across cases as load shifts, so per-case widening only moved the flake. Raise the lane defaults (DSH_COVERAGE_TEST_TIMEOUT_MS and the native --testTimeout) to 90s, align the oxlint and workflow-worker-thread case budgets, and keep the built-bin SPAWN_TIMEOUT_MS at 60s under a 90s outer budget. --- .github/workflows/ci.yml | 4 ++-- .../tests/workflow-worker-thread.spec.ts | 18 +++++++++--------- scripts/ci-workflow.spec.ts | 2 +- scripts/oxlint-contract.spec.ts | 8 ++++---- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9b3e5925..45d345b9a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -446,7 +446,7 @@ jobs: env: DSH_COVERAGE_MAX_WORKERS: '6' DSH_COVERAGE_PARTITIONS: '4' - DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' DSH_GATE_CONCURRENCY: '3' steps: - uses: actions/checkout@v6 @@ -505,7 +505,7 @@ jobs: run: >- pnpm exec vitest run --no-file-parallelism - --testTimeout 30000 + --testTimeout 90000 packages/shell/tool-pwsh/tests/loader.spec.ts packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts packages/workflow/tool-ralph/tests/integration.spec.ts diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts index acd2702dab..7ed206e934 100644 --- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts @@ -787,7 +787,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(result.error).toContain('raced the completion') expect(narration).toEqual(['started']) await handle.dispose() - }, 15_000) + }, 90_000) it('cancel() force-settles a script parked on a promise no hook owns, and TERMINATES its worker', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 50 } }) @@ -1027,7 +1027,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(provider.runs[0]!.disposeCalls).toBe(1) await handle.dispose() await ctx.fiber.dispose() - }, 15_000) + }, 90_000) it('dispose() on a wedged worker host-drives child disposal inside the grace: it returns with the children DISPOSED, not with their teardown still in flight', async () => { const { ctx, parent, provider } = await setup({ @@ -1062,7 +1062,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(provider.runs[0]!.disposed).toBe(true) const result = await handle.result expect(result.stopReason).toBe('cancelled') - }, 15_000) + }, 90_000) it('a live child disposed by the dispose() drive is disposed ONCE, and the worker\'s late dispose RPC still gets its ack (the script settles, not the grace)', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1127,7 +1127,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { // can finalize its state at run-end without dangling agents. expect(order.indexOf('run-end')).toBe(order.length - 1) await handle.dispose() - }, 15_000) + }, 90_000) it('graceful cancellation keeps pairing worker-authored: exactly one agent-end per start, nothing synthesized on top', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1305,7 +1305,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { await Promise.resolve() expect(result.stopReason).toBe('error') await handle.dispose() - }, 15_000) + }, 90_000) it('an uncaught exception inside the worker surfaces as an error result and reaps the in-flight child', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1331,7 +1331,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) await handle.dispose() - }, 15_000) + }, 90_000) it('a worker death pairs every stranded start: the synthesized cancelled agent-end precedes the error workflow/end', async () => { const { ctx, parent, provider } = await setup({ manual: true }) @@ -1366,7 +1366,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { ]) expect(order.indexOf('run-end')).toBe(order.length - 1) await handle.dispose() - }, 15_000) + }, 90_000) it('a dispose ack racing the worker death is dropped, not crashed (post after exit)', async () => { // Slow child disposal: the ack resolves only AFTER the worker died, so @@ -1395,7 +1395,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { // tight explicit bound (see the helper's doc comment). await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000) await handle.dispose() - }, 15_000) + }, 90_000) it('a worker death AFTER a cancel reports cancelled, not error', async () => { const { ctx, parent } = await setup({ config: { provider: 'stub', disposeGraceMs: 60_000 } }) @@ -1421,7 +1421,7 @@ describe('dsh-workflow-worker-thread', { timeout: 120_000 }, () => { expect(result.stopReason).toBe('cancelled') expect(result.error).toContain('stop it') await handle.dispose() - }, process.platform === 'win32' ? 30_000 : 15_000) + }, process.platform === 'win32' ? 90_000 : 15_000) }) describe('service API', () => { diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 7c0b762bdc..f9c8269903 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -134,7 +134,7 @@ describe('CI workflow', () => { )) const nativeTestCommand = nativeTestCommands.map(step => step.run).join('\n') expect(nativeTestCommand).toContain('--no-file-parallelism') - expect(nativeTestCommand).toContain('--testTimeout 30000') + expect(nativeTestCommand).toContain('--testTimeout 90000') expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts') expect(nativeTestCommand).toContain('workflow-worker-thread.spec.ts') diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 24fbbd4af0..1141067ad2 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -105,7 +105,7 @@ probePromise() rm(configPath, { force: true }), ]) } - }, 60_000) + }, 90_000) it('runs JavaScript compatibility and nursery rules', async () => { const suffix = randomUUID() @@ -152,7 +152,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 60_000) + }, 90_000) it('keeps the complete stylistic contract in Oxlint', async () => { const oxlintPath = join(repositoryRoot, '.oxlintrc.json') @@ -252,7 +252,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + rm(configPath, { force: true }), ]) } - }, 60_000) + }, 90_000) it('accepts an ignored-only staged selection', () => { const result = runOxlint([ @@ -371,6 +371,6 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + await rm(directory, { recursive: true, force: true }) } }, - 60_000, + 90_000, ) }) From b648ed75c9244135eae710274eff8002a7641c7c Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 17:43:57 +0800 Subject: [PATCH 019/130] test: unify the last Windows spawn budgets to the 90s pattern The built-bin help/usage case still used a win32-conditional 60/30s outer budget while serializing six runBuiltBin calls, and startProfileLifecycle lacked the execa timeout/killSignal the sibling helper has; the tool-ralph cases pinned 20-30s explicit timeouts that the 90s lane default cannot override. Align all of them to the SPAWN_TIMEOUT_MS + 30s (or 90s) pattern. --- apps/cli/tests/built-bin.e2e.ts | 4 +++- packages/workflow/tool-ralph/tests/integration.spec.ts | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c133320ace..2a688b5b00 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -150,6 +150,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', + timeout: SPAWN_TIMEOUT_MS, + killSignal: 'SIGKILL', reject: false, env: { DSH_HOME: fixture.home, @@ -396,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, process.platform === 'win32' ? 60_000 : 30_000) + }, SPAWN_TIMEOUT_MS + 30_000) it('reports SDK startup failure when stdin reaches EOF first', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index 124364e7b4..8b4d4e4e42 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -35,7 +35,7 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) { } describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { - it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', { timeout: 30_000 }, async () => { + it('uses distinct empty-seed children, shared cwd, and only the prior bounded handoff', { timeout: 90_000 }, async () => { const firstReport = { status: 'continue', summary: 'ROUND_ONE_HANDOFF', @@ -115,7 +115,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parentHandle.dispose() }) - it('reports the failed round and last good handoff when a child fails', { timeout: 30_000 }, async () => { + it('reports the failed round and last good handoff when a child fails', { timeout: 90_000 }, async () => { const firstReport = { status: 'continue', summary: 'ROUND_ONE_HANDOFF', @@ -235,7 +235,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => { await parentHandle.dispose() }) - it('cancels the real worker and fresh child to quiescence', { timeout: 20_000 }, async () => { + it('cancels the real worker and fresh child to quiescence', { timeout: 90_000 }, async () => { const { ctx, parent, parentHandle } = await mountRalph(['hang'], { maxRounds: 2 }) const children: Agent[] = [] const outcomes: string[] = [] From 6cbd3dda21a242835548b61be8a363caa4570737 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 18:20:43 +0800 Subject: [PATCH 020/130] test: give the multi-call built-bin cases a 210s outer budget The requires-profile and routes-help cases serialize 4-6 runBuiltBin calls, each with a 60s execa cap; under the loaded pool the 90s outer budget was exhausted before the last call and vitest truncated the run without the execa diagnostics. Raise both to SPAWN_TIMEOUT_MS * 3 + 30s. --- apps/cli/tests/built-bin.e2e.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 2a688b5b00..4515d70b2e 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -341,7 +341,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const result = await runBuiltBin(removed) expect(result.code).toBe(1) } - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 3 + 30_000) it('routes help and usage errors without activating startup-dependent rows', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) @@ -398,7 +398,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 3 + 30_000) it('reports SDK startup failure when stdin reaches EOF first', async () => { const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-startup-failure-')) From 0114dc1f817b7d5f594b19db1041301de3a47aac Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 26 Aug 2026 21:17:46 +0800 Subject: [PATCH 021/130] feat(web): polish the input trigger menu presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Candidate rows lead with domain icons instead of localized text prefixes; pointer and keyboard share one reducer-owned highlight (last input wins); drillable folder rows reveal a localized Browse-folder + Tab keycap hint with the library chevron; pending sources render skeleton bars; the menu spans the composer card. The editable @dir/ text decorates color-only — the domain icon now belongs exclusively to the settled reference chip. Composer placeholders advertise / and @, and the zh copy for commands is unified to 指令. --- ...trigger-menu-presentation-polish.i18n.yaml | 6 ++ ...26-web-trigger-menu-presentation-polish.md | 35 ++++++++ ...web-trigger-menu-presentation-polish.zh.md | 35 ++++++++ apps/web/tests/agent-preset-selection.e2e.ts | 5 ++ .../command-image-envelope.expected.e2e.ts | 2 +- .../conversation.expected.md | 2 +- .../goal-command-presentation/ui.expected.md | 2 +- .../markdown-cjk-strong/ui.expected.md | 2 +- .../expected/markdown-images/ui.expected.md | 2 +- .../markdown-inline-code-links/ui.expected.md | 2 +- .../expected/math-rendering/ui.expected.md | 2 +- .../reference-composer/menu.expected.md | 15 ++-- .../reference-composer/order.expected.md | 2 +- .../expected/skill-user-invoke/ui.expected.md | 2 +- .../stats-paged-history/ui.expected.md | 2 +- .../expected/steer-all/mid-steer.expected.md | 2 +- .../expected/steer-all/settled.expected.md | 2 +- apps/web/tests/goal-bar.e2e.ts | 2 +- apps/web/tests/image-display.expected.e2e.ts | 6 +- apps/web/tests/preview-boot.e2e.ts | 2 +- apps/web/tests/reference-composer.e2e.ts | 26 +++--- .../mid-stream.expected.md | 2 +- apps/web/tests/startup-auto-selection.e2e.ts | 2 +- apps/web/tests/subagent-conversation.e2e.ts | 6 +- apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +- apps/web/tests/support.ts | 4 +- packages/client/ui-chat/src/client/locale.ts | 4 +- .../ui-chat/tests/chat-view.client.spec.tsx | 4 +- .../src/client/input/decorations.ts | 4 +- .../input/editor/composer-editor.module.css | 19 +---- .../src/client/input/editor/text-ref.ts | 44 +++------- .../ui-conversation/src/client/locales.ts | 10 +-- .../tests/input-bar.client.spec.tsx | 32 +++---- .../tests/input-matrix.client.spec.tsx | 2 +- .../tests/submit-machine.client.spec.ts | 2 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 6 +- packages/client/ui-goal/src/client/locales.ts | 2 +- .../tests/goal-command-input.client.spec.tsx | 2 +- .../src/client/MenuView.module.css | 83 +++++++++++++++---- .../ui-input-trigger/src/client/MenuView.tsx | 55 ++++++++---- .../ui-input-trigger/src/client/controller.ts | 12 +++ .../ui-input-trigger/src/client/index.ts | 1 + .../ui-input-trigger/src/client/locales.ts | 6 +- .../ui-input-trigger/src/client/slots.ts | 7 ++ .../ui-input-trigger/src/core/contract.ts | 1 + .../client/ui-input-trigger/src/core/menu.ts | 11 ++- packages/client/ui-input-trigger/src/types.ts | 5 +- .../tests/apply.client.spec.ts | 2 +- .../tests/core-menu.client.spec.ts | 38 +++++++-- .../tests/menu-view.client.spec.tsx | 46 ++++++---- .../tests/service.client.spec.ts | 12 +++ .../client/ui-reference/src/client/index.ts | 6 +- .../client/ui-reference/src/client/locales.ts | 10 +-- .../tests/browser-plugin.client.spec.ts | 19 +++-- snapshots/web/approval-composer/session.jsonl | 2 +- snapshots/web/bash-abort-row/ui.expected.md | 2 +- snapshots/web/code-mode-round/session.jsonl | 8 +- snapshots/web/code-mode-round/ui.expected.md | 2 +- snapshots/web/cordis-tool-round/session.jsonl | 2 +- .../web/cordis-tool-round/ui.expected.md | 2 +- .../web/feedback-command/ack.expected.md | 2 +- snapshots/web/feedback-command/session.jsonl | 6 +- snapshots/web/fresh-round-trip/session.jsonl | 8 +- snapshots/web/fresh-round-trip/ui.expected.md | 2 +- .../web/goal-multi-turn-actions/session.jsonl | 16 ++-- .../goal-multi-turn-actions/ui.expected.md | 2 +- .../web/lifecycle-chrome/hero.expected.md | 2 +- .../lifecycle-chrome/plan-active.expected.md | 2 +- .../web/lifecycle-chrome/reloaded.expected.md | 2 +- snapshots/web/lifecycle-chrome/session.jsonl | 6 +- .../web/live-interactions/cancel.expected.md | 2 +- .../live-interactions/error-auth.expected.md | 2 +- .../web/live-interactions/loading.expected.md | 2 +- .../retry-exhausted.expected.md | 2 +- .../web/live-interactions/retry.expected.md | 2 +- .../running-draft.expected.md | 2 +- snapshots/web/live-interactions/session.jsonl | 6 +- snapshots/web/message-actions/ui.expected.md | 2 +- snapshots/web/minimal-preset/session.jsonl | 2 +- snapshots/web/minimal-preset/ui.expected.md | 2 +- .../permission-policy-context/session.jsonl | 28 +++---- .../web/plan-review/approved.expected.md | 2 +- snapshots/web/plan-review/session.jsonl | 10 +-- .../question-composer/answered.expected.md | 2 +- snapshots/web/question-composer/session.jsonl | 8 +- .../web/queue-actions/preserved.expected.md | 2 +- .../seeded-history/command-row.expected.md | 2 +- .../seeded-history/feedback-row.expected.md | 2 +- snapshots/web/seeded-history/ui.expected.md | 2 +- snapshots/web/skill-tool-row/ui.expected.md | 2 +- snapshots/web/steering/session.jsonl | 2 +- snapshots/web/steering/settled.expected.md | 2 +- .../web/subagent-conversation/ui.expected.md | 2 +- .../web/turn-tail-actions/running.expected.md | 2 +- snapshots/web/turn-tail-actions/session.jsonl | 8 +- .../web/turn-tail-actions/settled.expected.md | 2 +- .../usage-expanded.expected.md | 2 +- snapshots/web/web-search-round/session.jsonl | 2 +- snapshots/web/web-search-round/ui.expected.md | 2 +- 101 files changed, 498 insertions(+), 299 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md create mode 100644 .agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml new file mode 100644 index 0000000000..973b2da030 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.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/feature/2026-08-26-web-trigger-menu-presentation-polish.md +2026-08-26-web-trigger-menu-presentation-polish.md: 62582794cf46719a4ede3627164b4dda8c1d372d +2026-08-26-web-trigger-menu-presentation-polish.zh.md: 627f0fb2ce152bf3a505cea892eeda484fcd29e4 diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md new file mode 100644 index 0000000000..62582794cf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.md @@ -0,0 +1,35 @@ +# Agent Note: Web trigger menu presentation polish + +Status: implemented + +English | [中文](2026-08-26-web-trigger-menu-presentation-polish.zh.md) + +## Problem + +The Web composer's `/` and `@` trigger menu carried several presentation defects that made the reference flow harder to read and operate. Candidate rows spelled their kind as a localized text prefix (`Folder · name/`, `Session · label`) that duplicated the section title and pushed the name right. Pointer hover used a CSS `:hover` tint while keyboard navigation drove the reducer-owned highlight, so two rows could look focused at once. The drillable-folder affordance was a raw `›` text glyph, unlike every other chevron in the composer, and nothing told the user that Tab drills into the highlighted folder. The pending-source state was a bare "Loading…" text row. The editable `@dir/` text a drill leaves behind rendered a folder icon before the `@`, visually double-marking a token that is not a settled chip. The composer placeholders never mentioned that `/` and `@` exist ([#3080](https://github.com/deepseek-harness/deepseek-harness/issues/3080)). + +## Decision + +Candidate rows lead with a domain icon instead of a text prefix: `InputTriggerCandidate.icon` narrows from `string` to the closed union `InputTriggerCandidateIcon` (`file | folder | session`), the menu view maps it to `ReferenceIcon`, and `ui-reference` emits bare names (`folderx/`, session label). The `candidate.file`/`candidate.folder`/`candidate.session` locale keys are deleted; the session section title is `对话`/`Sessions`. The menu spans the composer card edge to edge (`left: 0; right: 0`), and a pending source renders two breathing skeleton bars in item cell metrics instead of the loading text row. + +Pointer and keyboard share one highlight, last input wins: a `hover` MenuEvent parks the reducer-owned highlight on a ready row, `MenuView` routes it from `onMouseMove` (not `mouseenter`, so keyboard-scrolling rows under a resting pointer cannot steal the highlight back), and the CSS `:hover` tint is gone. + +The drill affordance on the highlighted folder row is the library `IconChevronRightOutline14` in the quiet `--dsw-alias-label-caption` tint the access-mode chevron uses, preceded by a localized "Browse folder" caption and a `Tab` keycap that reveal only while the row holds the shared highlight. + +A token still carrying its trigger character is editable text, not a settled chip: the text-ref decoration colors it and nothing more, and the domain icon belongs exclusively to the settled `ReferenceChipNode`. The former appearance channel (scan `appearance` field, `TextRefNode.__appearance`, `data-ref-appearance` DOM attribute, CSS `::before` icon) is deleted end to end. + +Composer placeholders advertise both triggers (`描述你想要构建的内容… / 调用指令 @ 文件或会话` / `Describe what you want to build... / commands, @ files or sessions`), and the zh copy for commands is unified from 命令 to 指令 across `ui-chat`, `ui-conversation`, `ui-goal`, and `ui-input-trigger`. + +## Alternatives considered + +**Keep the CSS `:hover` tint alongside the keyboard highlight.** Rejected: two rows can look focused at once while `aria-activedescendant` names only one, and Enter acts on the keyboard row while the eye may rest on the hovered one. + +**Route hover from `mouseenter`.** Rejected: when arrow keys scroll new rows under a stationary pointer, each row entering the pointer re-fires `mouseenter` and steals the highlight the user just moved; `mousemove` fires only on real pointer motion. + +**Keep the folder icon on the editable `@dir/` text.** Rejected: the icon before the trigger character double-marks the token and erases the visual distinction between "still editable text" and "settled chip"; reserving the icon for the chip makes the two states readable at a glance. + +**Show the Tab hint on every drillable row.** Rejected: idle rows carrying persistent keycaps add noise; the hint teaches the key exactly when it applies — while that row is the one Tab would act on. + +## Consequences + +The kind information every row used to spell in text now rides the icon and section title; a future candidate kind must extend `InputTriggerCandidateIcon` and pick an icon rather than pass an arbitrary string. Pointer motion round-trips through the reducer (`hover` is a no-op for the already-highlighted row, so mousemove storms do not churn state). Drill discoverability rests on the highlight: an idle folder row shows only its chevron until hovered or reached by keys. Deferred follow-ups — settle-on-space for exact-match tokens, candidate description content, back navigation after a drill, `name` vs `name/` labels, and reference search latency — are tracked in [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154). diff --git a/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md new file mode 100644 index 0000000000..627f0fb2ce --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md @@ -0,0 +1,35 @@ +# Agent Note:Web 触发菜单呈现打磨 + +Status: implemented + +[English](2026-08-26-web-trigger-menu-presentation-polish.md) | 中文 + +## Problem + +Web composer 的 `/` 与 `@` 触发菜单存在多处呈现缺陷,使引用流程更难阅读和操作。候选行用本地化文字前缀标注类型(`Folder · name/`、`Session · label`),既与 section 标题重复又把名称挤向右侧。指针悬停用 CSS `:hover` 着色,而键盘导航驱动 reducer 持有的高亮,两行可能同时呈现焦点态。可下钻文件夹的操作标记是裸文本 `›`,与 composer 中其他 chevron 不一致,且没有任何提示告诉用户 Tab 可以下钻高亮的文件夹。来源加载中状态是一行"正在加载…"文字。下钻留下的可编辑 `@dir/` 文本在 `@` 前渲染文件夹图标,对一个并非 settled chip 的 token 形成视觉双重标记。composer 的 placeholder 从未提及 `/` 和 `@` 的存在([#3080](https://github.com/deepseek-harness/deepseek-harness/issues/3080))。 + +## Decision + +候选行以领域图标开头,不再用文字前缀:`InputTriggerCandidate.icon` 从 `string` 收窄为封闭联合 `InputTriggerCandidateIcon`(`file | folder | session`),菜单视图将其映射到 `ReferenceIcon`,`ui-reference` 只输出裸名称(`folderx/`、session 标签)。删除 `candidate.file`/`candidate.folder`/`candidate.session` 三个 locale 键;session section 标题改为 `对话`/`Sessions`。菜单与 composer 卡片左右等宽(`left: 0; right: 0`),加载中的来源渲染两条与候选行同尺寸的呼吸骨架条,替代加载文字行。 + +指针与键盘共享单一高亮,后到者优先:新增 `hover` MenuEvent 把 reducer 持有的高亮停在某个就绪行上,`MenuView` 从 `onMouseMove` 路由(不用 `mouseenter`,避免键盘滚动把新行送到静止指针下时抢回高亮),CSS `:hover` 着色整体移除。 + +高亮文件夹行上的下钻标记改为库内 `IconChevronRightOutline14`,使用访问模式 chevron 同款的弱色 `--dsw-alias-label-caption`,左侧为本地化"进入目录"文字加 `Tab` 键帽提示,仅当该行持有共享高亮时显示。 + +仍带触发符的 token 是可编辑文本而非 settled chip:text-ref 装饰只做染色,领域图标专属于 settled 的 `ReferenceChipNode`。原有的 appearance 通道(扫描的 `appearance` 字段、`TextRefNode.__appearance`、`data-ref-appearance` DOM 属性、CSS `::before` 图标)端到端删除。 + +composer placeholder 同时提示两个触发符(`描述你想要构建的内容… / 调用指令 @ 文件或会话` / `Describe what you want to build... / commands, @ files or sessions`),并将 `ui-chat`、`ui-conversation`、`ui-goal`、`ui-input-trigger` 中命令的中文文案统一为"指令"。 + +## Alternatives considered + +**保留 CSS `:hover` 着色与键盘高亮并存。** 被否:两行可能同时呈现焦点态,而 `aria-activedescendant` 只指向一行;Enter 作用于键盘行,视线却可能停在悬停行上。 + +**从 `mouseenter` 路由悬停。** 被否:方向键把新行滚动到静止指针下方时,每个进入指针的行都会重新触发 `mouseenter`,抢走用户刚移走的高亮;`mousemove` 只在指针真实移动时触发。 + +**保留可编辑 `@dir/` 文本上的文件夹图标。** 被否:触发符前的图标对 token 形成双重标记,抹掉了"仍可编辑的文本"与"settled chip"之间的视觉区分;图标专属于 chip 才能让两种状态一眼可辨。 + +**在所有可下钻行上常驻 Tab 提示。** 被否:空闲行常驻键帽增加噪音;提示恰好在其生效时出现——该行正是 Tab 将作用的行。 + +## Consequences + +过去每行用文字拼写的类型信息现在由图标和 section 标题承载;未来新增候选类型必须扩展 `InputTriggerCandidateIcon` 并选定图标,而非传任意字符串。指针移动经 reducer 往返(`hover` 对已高亮行是 no-op,mousemove 风暴不会搅动状态)。下钻的可发现性依赖高亮:空闲文件夹行在被悬停或键盘到达前只显示 chevron。延后的跟进项——精确匹配 token 的空格 settle、候选 description 内容、下钻后的回退导航、`name` 与 `name/` 标签、引用搜索延迟——记录在 [#3154](https://github.com/deepseek-harness/deepseek-harness/issues/3154)。 diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 4c0cd69705..ec118a1ed9 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -256,6 +256,10 @@ describe('web e2e: agent-preset selection', () => { // remains outside every preset. expect(onMinimal.some(option => option.startsWith('goal'))).toBe(false) expect(onMinimal.some(option => option.startsWith('model'))).toBe(true) + // Escape closes the menu before clearing: Playwright's fill('') leaves no + // caret, so trigger tracking never sees the emptied draft, and the open + // full-width menu would swallow the preset-seat click below. + await page.keyboard.press('Escape') await composer.fill('') // Switching back up reaches the host at all — the chip compares the pick @@ -273,6 +277,7 @@ describe('web e2e: agent-preset selection', () => { expect(onStandard.some(option => option.startsWith('compact'))).toBe(true) expect(onStandard.some(option => option.startsWith('goal'))).toBe(true) expect(onStandard.some(option => option.startsWith('plan'))).toBe(true) + await page.keyboard.press('Escape') await composer.fill('') }, 90_000) diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index cc0b7a01a2..e378072db1 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -21,7 +21,7 @@ async function freshComposer(): Promise { fireEvent.click(start) return await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index 738a1b11a3..d30b4a4e1e 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -42,7 +42,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index 42811d29ae..c10eef6aac 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -14,7 +14,7 @@ - img - img - text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]" -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index adae2f723e..65c73793b2 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -45,7 +45,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 85c537bb18..3c6a9475f8 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -24,7 +24,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index d940beabc9..228443ee1e 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -36,7 +36,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 5561c3574e..e063215b77 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -40,7 +40,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index 6668011066..0a2fa81c19 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -1,9 +1,10 @@ - listbox "Trigger suggestions": - text: Files & folders - - option "Folder · folderx/ folderx Browse folder" [selected]: - - text: Folder · folderx/ folderx - - button "Browse folder": › - - option "File · reference.txt reference.txt" - - text: Session conversations - - option "Session · Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" - - option "Session · Research notes reference-source-session · {{cwd}} · {{timestamp}}" + - option "folderx/ folderx Browse folder" [selected]: + - text: folderx/ folderx + - button "Browse folder": + - img + - option "reference.txt reference.txt" + - text: Sessions + - option "Reference order target reference-order-target-session · {{cwd}} · {{timestamp}}" + - option "Research notes reference-source-session · {{cwd}} · {{timestamp}}" diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 75543314b2..7cec2eaf08 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -13,7 +13,7 @@ - button "Session recall Research notes": - img - text: Session recall Research notes -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index b06b3aae82..54ad367774 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -34,7 +34,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 1722b90b13..dc2200a0b3 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -375,7 +375,7 @@ - text: 7/25 {{clock}} Ran for {{duration}} - button "Back to bottom": - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c201520e8d..c3847df394 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -31,7 +31,7 @@ - text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index 885143a0f6..2bff42b96c 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -44,7 +44,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 06bc22a74b..b9c7695066 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -45,7 +45,7 @@ describe('web e2e: goal bar clear convergence', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear')) // Startup reuses the fixture workspace's blank session, keeping this // command independent of alpha's running replay and pending question. - const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') + const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') await input.waitFor({ timeout: 10_000 }) await input.fill('/goal guard rapid clear clicks') await input.press('Enter') diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 6c8107d1bd..37be0f24ac 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -90,7 +90,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy // this assembled lane pins the intake chain over the built graph. const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface @@ -165,7 +165,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov fireEvent.click(start) const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface @@ -220,7 +220,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async ( const textarea = await waitFor(() => { const surface = document.querySelector( - '[data-composer-input][data-placeholder="Describe what you want to build"]', + '[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]', ) if (surface === null) throw new Error('composer surface missing') return surface diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 0d054a293d..37c8e48fec 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -310,7 +310,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { const configureLater = page.getByRole('button', { name: 'Configure later' }) await configureLater.waitFor({ timeout: 30_000 }) await configureLater.click() - await page.locator('[data-composer-input][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 30_000 }) const exercised = await page.evaluate(async () => { diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 3f170b6558..c3d80722e5 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -150,14 +150,14 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Files & folders') - expect(snapshot).toContain('Session conversations') + expect(snapshot).toContain('Sessions') expect(snapshot).not.toContain('text: reference Files & folders') - expect(snapshot).toContain('File \u00b7 reference.txt') - expect(snapshot).toContain('Session \u00b7 Research notes') + expect(snapshot).toContain('reference.txt') + expect(snapshot).toContain('Research notes') expect(snapshot).not.toContain('text: Subagents') await input.fill('@reference') - await menu.getByRole('option', { name: /File \u00b7 reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() // The pick lands an atomic chip: a real DOM capsule carrying the domain // icon and the label (the canonical reference text lives on the node and // expands on submit; the surface text is the label plus the separator). @@ -167,7 +167,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => input.textContent()).toBe('reference.txt ') await input.fill('@Research') - await menu.getByRole('option', { name: /Session \u00b7 Research notes/ }).click() + await menu.getByRole('option', { name: /Research notes/ }).click() const sessionReference = page.locator('[data-composer-chip]').last() await expect.poll(() => sessionReference.textContent()).toBe('Research notes') await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) @@ -183,7 +183,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@reference') - await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) // The #2813 gesture: collapse the caret to the document start, directly @@ -192,7 +192,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await page.keyboard.press('ControlOrMeta+A') await page.keyboard.press('ArrowLeft') await page.keyboard.type('@Research') - await menu.getByRole('option', { name: /Session · Research notes/ }).click() + await menu.getByRole('option', { name: /Research notes/ }).click() // Both chips survive the boundary insert: the session chip lands ahead of // the intact file chip. @@ -212,7 +212,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through const menu = page.getByRole('listbox', { name: 'Trigger suggestions' }) await input.fill('@reference') - await menu.getByRole('option', { name: /File · reference\.txt/ }).click() + await menu.getByRole('option', { name: /reference\.txt/ }).click() await expect.poll(() => input.locator('[data-composer-chip]').count()).toBe(1) // First ArrowLeft crosses the trailing space; the second steps across the @@ -250,7 +250,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through // as an atomic chip — folder glyph, no trigger character, one unit. await writeComposerDraft(page, input, '@folderx') // First folder query on this page: allow the Host index a cold start. - await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor({ timeout: 60_000 }) + await menu.getByRole('option', { name: /^folderx\// }).waitFor({ timeout: 60_000 }) await page.keyboard.press('Enter') const chip = input.locator('[data-composer-chip]').last() await expect.poll(() => chip.textContent()).toBe('folderx/') @@ -260,18 +260,18 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through // Tab drills: the literal descent text stays editable and the open menu // lists the folder's children. await writeComposerDraft(page, input, '@folderx') - await menu.getByRole('option', { name: /Folder · folderx\// }).waitFor() + await menu.getByRole('option', { name: /^folderx\// }).waitFor() await page.keyboard.press('Tab') await expect.poll(() => input.textContent()).toBe('@folderx/') - await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() // The row chevron drills the same way by pointer. await writeComposerDraft(page, input, '@folderx') - const row = menu.getByRole('option', { name: /Folder · folderx\// }) + const row = menu.getByRole('option', { name: /^folderx\// }) await row.waitFor() await row.getByRole('button', { name: 'Browse folder' }).click() await expect.poll(() => input.textContent()).toBe('@folderx/') - await menu.getByRole('option', { name: /File · child\.txt/ }).waitFor() + await menu.getByRole('option', { name: /child\.txt/ }).waitFor() await page.keyboard.press('Escape') expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index ebcb6163c7..cd696c5411 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -24,7 +24,7 @@ - button "Copy" - code: "const first: number = 1 const second = \"two\" let tail" - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index e257aa031d..1404402b4f 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -130,7 +130,7 @@ describe('web e2e: startup auto-selection', () => { expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true) releaseOpening() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) acknowledgeReloadConnectionLoss(tripwire, warningsBefore) diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 147bf7ea50..ec554e3863 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -393,7 +393,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await page.locator('[data-composer-seat]').evaluate(element => getComputedStyle(element).visibility)).toBe('hidden') releaseCatalog() - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.waitFor({ timeout: 15_000 }) await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true) acknowledgeReloadConnectionLoss(tripwire, warningStart) @@ -417,7 +417,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = resolveEnded() }) }) - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.fill(FOLLOWUP) await input.press('Enter') await expect.poll( @@ -500,7 +500,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = .click() await page.getByRole('button', { name: '3 subagents' }).hover() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - await page.getByRole('textbox', { name: 'Message the agent' }).waitFor() + await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor() const forkResponse = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/fork') await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click() diff --git a/apps/web/tests/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts index 1975e0b614..c6154c5177 100644 --- a/apps/web/tests/subagent-interrupt-ui.e2e.ts +++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts @@ -259,7 +259,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co .getByRole('button').first().click() await page.getByRole('button', { name: /1 subagent/ }).click() await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click() - const input = page.getByRole('textbox', { name: 'Message the agent' }) + const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }) await input.waitFor({ timeout: 15_000 }) expect(await input.isDisabled()).toBe(false) diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index b43fcbaa76..2794ae44bb 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -83,7 +83,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo await dialog.getByRole('button', { name: 'Open', exact: true }).click() // The pick connected the workspace: the blank session's live composer // replaces the locked placeholder and enables. - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]') .waitFor({ timeout: 15_000 }) } @@ -106,7 +106,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = ' await pathInput.fill(join(root, name)) await pathInput.press('Enter') await dialog.getByRole('button', { name: '打开', exact: true }).click() - await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容"]') + await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或会话"]') .waitFor({ timeout: 15_000 }) } diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index a693a7760f..78321ef9f4 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -91,9 +91,9 @@ export const zh = { 'duration.seconds': '{seconds}秒', 'duration.minutes': '{minutes}分{seconds}秒', 'command.running': '执行中…', - 'command.failed': '命令失败', + 'command.failed': '指令失败', 'command.done': '已完成', - 'command.title': '命令', + 'command.title': '指令', 'row.running': '运行中', 'row.failed': '失败', 'json.truncated': '… 已截断,共 {total} 字符', diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1f200a2029..ec6af626ee 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -1568,7 +1568,7 @@ describe('ChatView', () => { }) const fv = render() expect(fv.container.querySelector('[data-state="error"]')).not.toBeNull() - expect(fv.getByText('命令失败')).toBeTruthy() + expect(fv.getByText('指令失败')).toBeTruthy() expect(fv.getByText('失败')).toBeTruthy() // Still executing: running state with the executing copy. @@ -1585,7 +1585,7 @@ describe('ChatView', () => { nodes: [command({ seq: 8, commandId: 'cmd-4' as CommandNode['commandId'], name: null, args: null, outcome: { kind: 'success' } })], }) const ov = render() - expect(ov.getByText('命令')).toBeTruthy() + expect(ov.getByText('指令')).toBeTruthy() expect(ov.getByText('已完成')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/src/client/input/decorations.ts b/packages/client/ui-conversation/src/client/input/decorations.ts index 5716c8108f..e20a006dac 100644 --- a/packages/client/ui-conversation/src/client/input/decorations.ts +++ b/packages/client/ui-conversation/src/client/input/decorations.ts @@ -18,8 +18,6 @@ export interface TextRefRange { readonly start: number readonly end: number readonly trigger: '/' | '@' - /** Optional icon domain for syntax-recognizable plain references. */ - readonly appearance?: 'folder' } /** Token matcher: a trigger char at line start or after whitespace, then a word-ish name (never crosses \n). */ @@ -59,7 +57,7 @@ export function scanTextRefs( const start = folder.index + (folder[1]?.length ?? 0) const end = start + token.length if (!out.some(range => range.start < end && range.end > start)) { - out.push({ start, end, trigger: '@', appearance: 'folder' }) + out.push({ start, end, trigger: '@' }) } } return out.sort((left, right) => left.start - right.start) diff --git a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css index 40f6f2d075..25b884b689 100644 --- a/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css +++ b/packages/client/ui-conversation/src/client/input/editor/composer-editor.module.css @@ -3,24 +3,11 @@ this sheet covers text-level decorations). */ /* Plain-text reference: chip family colors over the draft's own glyphs. - clone keeps rounded ends on soft-wrap fragments. */ + clone keeps rounded ends on soft-wrap fragments. Color only, no icon — + a token still carrying its trigger character is editable text; the domain + icon belongs to the settled chip alone. */ .textRef { color: var(--dsw-alias-state-business-primary); box-decoration-break: clone; -webkit-box-decoration-break: clone; } - -/* Folder references carry the domain glyph as an icon prefix (the bubble's - IconFolderClose16 asset as a currentcolor mask). The literal token text — - trigger character included — stays intact: a Lexical text node cannot - split out its trigger character the way the old backdrop overpainted it. */ -.textRef[data-ref-appearance='folder']::before { - display: inline-block; - width: 14px; - height: 14px; - margin-right: 2px; - vertical-align: -2px; - background-color: currentcolor; - content: ''; - mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cpath%20transform%3D%22translate(1.5%202.429)%22%20d%3D%22M5.05582%200.518756L4.50669%200.86654L5.05582%200.518756ZM13%209.4837L13.65%209.4837L13.65%203.53962L13%203.53962L12.35%203.53962L12.35%209.4837L13%209.4837ZM11.3264%201.86603L11.3264%201.21603L6.52313%201.21603L6.52313%201.86603L6.52313%202.51603L11.3264%202.51603L11.3264%201.86603ZM5.58054%201.34727L6.12968%200.999489L5.60495%200.170972L5.05582%200.518756L4.50669%200.86654L5.03141%201.69506L5.58054%201.34727ZM4.11323%201.23058e-13L4.11323%20-0.65L1.67359%20-0.65L1.67359%205.00699e-14L1.67359%200.65L4.11323%200.65L4.11323%201.23058e-13ZM0%201.67359L-0.65%201.67359L-0.65%209.4837L0%209.4837L0.65%209.4837L0.65%201.67359L0%201.67359ZM11.3264%2011.1573L11.3264%2010.5073L1.67359%2010.5073L1.67359%2011.1573L1.67359%2011.8073L11.3264%2011.8073L11.3264%2011.1573ZM0%209.4837L-0.65%209.4837C-0.65%2010.767%200.390308%2011.8073%201.67359%2011.8073L1.67359%2011.1573L1.67359%2010.5073C1.10828%2010.5073%200.65%2010.049%200.65%209.4837L0%209.4837ZM1.67359%205.00699e-14L1.67359%20-0.65C0.390307%20-0.65%20-0.65%200.390309%20-0.65%201.67359L0%201.67359L0.65%201.67359C0.65%201.10828%201.10828%200.65%201.67359%200.65L1.67359%205.00699e-14ZM5.05582%200.518756L5.60495%200.170972C5.28121%20-0.340193%204.71829%20-0.65%204.11323%20-0.65L4.11323%201.23058e-13L4.11323%200.65C4.27282%200.65%204.4213%200.731715%204.50669%200.86654L5.05582%200.518756ZM6.52313%201.86603L6.52313%201.21603C6.36354%201.21603%206.21507%201.13431%206.12968%200.999489L5.58054%201.34727L5.03141%201.69506C5.35515%202.20622%205.91808%202.51603%206.52313%202.51603L6.52313%201.86603ZM13%203.53962L13.65%203.53962C13.65%202.25634%2012.6097%201.21603%2011.3264%201.21603L11.3264%201.86603L11.3264%202.51603C11.8917%202.51603%2012.35%202.97431%2012.35%203.53962L13%203.53962ZM13%209.4837L12.35%209.4837C12.35%2010.049%2011.8917%2010.5073%2011.3264%2010.5073L11.3264%2011.1573L11.3264%2011.8073C12.6097%2011.8073%2013.65%2010.767%2013.65%209.4837L13%209.4837Z%22%20fill%3D%22black%22%2F%3E%3C%2Fsvg%3E") center / contain no-repeat; -} diff --git a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts index b229948eba..9f57e4c10e 100644 --- a/packages/client/ui-conversation/src/client/input/editor/text-ref.ts +++ b/packages/client/ui-conversation/src/client/input/editor/text-ref.ts @@ -3,11 +3,13 @@ * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): * a `/name` or `@name` token whose name is on the trigger's lexicon, and * syntax-recognizable `@dir/` folder tokens, render in the chip family - * colors. Pure derivation as before — the entity transform converts matching - * text into TextRefNode and back as edits move it in and out of match shape; - * no occurrence identity exists. + * colors. Color only, no icon: a token still carrying its trigger character + * is editable text, not a settled chip — the domain icon marks exactly the + * settled state. Pure derivation as before — the entity transform converts + * matching text into TextRefNode and back as edits move it in and out of + * match shape; no occurrence identity exists. */ -import type { EditorConfig, LexicalEditor, NodeKey, SerializedTextNode, Spread } from 'lexical' +import type { EditorConfig, LexicalEditor, SerializedTextNode } from 'lexical' import { TextNode } from 'lexical' import { registerLexicalTextEntity } from '@lexical/text' import { mergeRegister } from '@lexical/utils' @@ -16,15 +18,10 @@ import { scanTextRefs } from '../decorations.ts' import css from './composer-editor.module.css' /** JSON form of one text-ref node. */ -export type SerializedTextRefNode = Spread<{ - appearance?: 'folder' -}, SerializedTextNode> +export type SerializedTextRefNode = SerializedTextNode /** One matched plain-text reference as a styled, fully editable text node. */ export class TextRefNode extends TextNode { - /** Optional icon domain for syntax-recognizable plain references. */ - __appearance: 'folder' | undefined - /** Lexical node registry type tag. */ static override getType(): string { return 'composer-text-ref' @@ -36,7 +33,7 @@ export class TextRefNode extends TextNode { * @returns a copy carrying the same NodeKey. */ static override clone(node: TextRefNode): TextRefNode { - return new TextRefNode(node.__text, node.__appearance, node.__key) + return new TextRefNode(node.__text, node.__key) } /** @@ -45,7 +42,7 @@ export class TextRefNode extends TextNode { * @returns a fresh node. */ static override importJSON(json: SerializedTextRefNode): TextRefNode { - const node = new TextRefNode(json.text, json.appearance) + const node = new TextRefNode(json.text) node.setFormat(json.format) node.setDetail(json.detail) node.setMode(json.mode) @@ -53,22 +50,11 @@ export class TextRefNode extends TextNode { return node } - /** - * @param text - the matched token text. - * @param appearance - optional icon domain (folder tokens). - * @param key - Lexical clone-path key; absent for fresh nodes. - */ - constructor(text: string, appearance?: 'folder', key?: NodeKey) { - super(text, key) - this.__appearance = appearance - } - /** Serialize to the JSON node form. */ override exportJSON(): SerializedTextRefNode { return { ...super.exportJSON(), type: 'composer-text-ref', - ...(this.__appearance === undefined ? {} : { appearance: this.__appearance }), } } @@ -77,7 +63,6 @@ export class TextRefNode extends TextNode { const el = super.createDOM(config) el.classList.add(css.textRef ?? 'textRef') el.setAttribute('data-composer-text-ref', '') - if (this.__appearance !== undefined) el.setAttribute('data-ref-appearance', this.__appearance) return el } @@ -92,15 +77,6 @@ export class TextRefNode extends TextNode { } } -/** - * Folder-shape probe for one matched token (the appearance bit). - * @param token - matched token text. - * @returns 'folder' for `@dir/` shapes; undefined otherwise. - */ -function appearanceOf(token: string): 'folder' | undefined { - return token.startsWith('@') && token.endsWith('/') ? 'folder' : undefined -} - /** * Register the plain-text reference entity transform. The claim decoration * has precedence on the leading-token seat: while a command claim holds, the @@ -130,7 +106,7 @@ export function registerTextRefDecoration( editor, getMatch, TextRefNode, - node => new TextRefNode(node.getTextContent(), appearanceOf(node.getTextContent())), + node => new TextRefNode(node.getTextContent()), ), ) } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 3ab8410590..480f73764b 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -13,13 +13,13 @@ export const zh = { 'hint.goal': '输入目标,智能体将持续执行', 'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除', 'placeholder.plan': PLAN_NEXT_ACTION_ZH, - 'placeholder.default': '给智能体发消息', + 'placeholder.default': '发消息或做任务… / 调用指令 @ 文件或会话', 'placeholder.unavailable': '会话不可用', 'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行', - 'placeholder.hero': '描述你想要构建的内容', + 'placeholder.hero': '描述你想要构建的内容… / 调用指令 @ 文件或会话', 'placeholder.workspace': '选择一个工作区开始', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', - 'input.commands': '命令', + 'input.commands': '指令', 'input.stop': '停止生成', 'input.send': '发送消息', 'input.accessMode': '访问模式,当前:{name}', @@ -158,10 +158,10 @@ export const en = { 'hint.goal': 'describe the objective for a long-running task', 'hint.goal.active': 'goal active — edit / pause / resume / clear', 'placeholder.plan': PLAN_NEXT_ACTION_EN, - 'placeholder.default': 'Message the agent', + 'placeholder.default': 'Message or run a task... / commands, @ files or sessions', 'placeholder.unavailable': 'Session unavailable', 'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run', - 'placeholder.hero': 'Describe what you want to build', + 'placeholder.hero': 'Describe what you want to build... / commands, @ files or sessions', 'placeholder.workspace': 'Choose a workspace to start', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.commands': 'Commands', diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 6fd24ce020..7fa6c189a5 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -427,9 +427,9 @@ describe('Enter semantics', () => { }) it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => { - expect(bench({ running: true }).placeholder).toBe('给智能体发消息') - expect(bench({ queue: [row('q-1')] }).placeholder).toBe('给智能体发消息') - expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('给智能体发消息') + expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') + expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') + expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') expect(bench({ running: true, queue: [row('q-1')], @@ -437,7 +437,7 @@ describe('Enter semantics', () => { address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' }, parentAvailable: true, }, - }).placeholder).toBe('给智能体发消息') + }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') expect(bench({ running: true, queue: [row('q-1')], @@ -449,7 +449,7 @@ describe('Enter semantics', () => { running: true, queue: [row('q-1')], commandMenuOpen: true, - }).placeholder).toBe('给智能体发消息') + }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') // The steer hint intentionally outranks the plan placeholder: while it // shows, the whole-queue gesture is genuinely available in plan mode. expect(bench({ @@ -781,7 +781,7 @@ describe('running and lock semantics', () => { }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('父会话已离线,无法继续发送;仍可停止当前运行') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect(button.getAttribute('aria-label')).toBe('发送消息') expect(button.disabled).toBe(true) expect(interruptButton?.disabled).toBe(false) @@ -829,7 +829,7 @@ describe('running and lock semantics', () => { const { textarea, view } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') expect(placeholderOf(view.container)).toBe('会话不可用') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) }) it('idle primary sends and disables on empty draft', () => { @@ -945,7 +945,7 @@ describe('running and lock semantics', () => { it('disabled state shows the unavailable placeholder; custom placeholder wins', () => { expect(bench({ disabled: true }).placeholder).toBe('会话不可用') const live = bench() - expect(live.placeholder).toBe('给智能体发消息') + expect(live.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') const custom = bench({ placeholder: 'Custom placeholder' }) expect(custom.placeholder).toBe('Custom placeholder') }) @@ -962,7 +962,7 @@ describe('running and lock semantics', () => { expect(editableOf(textarea)).toBe(false) expect(textarea.getAttribute('aria-haspopup')).toBe('menu') expect(textarea.getAttribute('aria-expanded')).toBe('false') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) fireEvent.click(textarea) fireEvent.keyDown(textarea, { key: 'Enter' }) @@ -992,7 +992,7 @@ describe('running and lock semantics', () => { expect(entering.placeholder).toBe('描述你的任务以生成计划') // Pending exit: target is default again. const leaving = bench({ plan: { active: true, pending: true } }) - expect(leaving.placeholder).toBe('给智能体发消息') + expect(leaving.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或会话') // Owner placeholder outranks the plan swap. const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' }) expect(custom.placeholder).toBe('Custom placeholder') @@ -1153,12 +1153,14 @@ describe('decorations', () => { expect(view.container.querySelector('[data-composer-text-ref]')).toBeNull() }) - it('a directory completion carries the folder appearance without changing its plain text', () => { + it('a directory completion decorates color-only: literal text, no icon seat', () => { const { view, shell } = bench() act(() => { shell.setDraft('see @src/components/') }) const mark = view.container.querySelector('[data-composer-text-ref]') expect(mark?.textContent).toBe('@src/components/') - expect(mark?.getAttribute('data-ref-appearance')).toBe('folder') + // The trigger character marks editable text; the domain icon is the + // settled chip's alone. + expect(mark?.getAttribute('data-ref-appearance')).toBeNull() expect(shell.snapshot.draft).toBe('see @src/components/') }) @@ -1259,7 +1261,7 @@ describe('strips and variants', () => { describe('command launcher chrome and control seats', () => { it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries', () => { const { view, slotCalls } = bench() - expect(view.getByLabelText('命令')).toBeTruthy() + expect(view.getByLabelText('指令')).toBeTruthy() // Capability absent (no projection value): the chip renders nothing. expect(view.queryByLabelText(/^访问模式/)).toBeNull() // Every seat dispatched, nothing rendered (render passes may repeat; the @@ -1275,7 +1277,7 @@ describe('command launcher chrome and control seats', () => { const toggleCommandMenu = vi.fn() const { view, shell, menuLauncher } = bench({ draft: 'draft text', toggleCommandMenu }) act(() => { shell.editor.update(() => { $selectDetectSpan({ start: 2, end: 7 }) }, { discrete: true }) }) - const launcher = view.getByLabelText('命令') + const launcher = view.getByLabelText('指令') expect(launcher.getAttribute('aria-expanded')).toBe('false') fireEvent.click(launcher) expect(toggleCommandMenu).toHaveBeenCalledExactlyOnceWith({ start: 2, end: 7 }) @@ -1425,7 +1427,7 @@ describe('command launcher chrome and control seats', () => { it('disabled locks the Access chip and command launcher (running does not)', () => { const permissions = { options: [{ value: 'workspace-write', name: 'workspace-write' }], currentValue: 'workspace-write' } const { view } = bench({ disabled: true, permissions }) - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect((view.getByLabelText(/^访问模式/) as HTMLButtonElement).disabled).toBe(true) cleanup() const live = bench({ running: true, permissions }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 8d05546540..2133d03477 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -305,7 +305,7 @@ describe('matrix row: locked (session disabled)', () => { it('disables the textarea and chrome; the machine currency is untouched', () => { const { view, textarea, shell } = bench({ disabled: true }) expect(textarea.getAttribute('aria-disabled')).toBe('true') - expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('指令') as HTMLButtonElement).disabled).toBe(true) expect(shell.snapshot.phase).toBe('plain') }) diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 1230444cd5..7a1a63a8b6 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -338,7 +338,7 @@ describe('decorations: scanTextRefs', () => { it('recognizes directory paths independently of the dynamic lexicon', () => { const out = scanTextRefs('see @src/x/ now', new Map()) - expect(out).toEqual([{ start: 4, end: 11, trigger: '@', appearance: 'folder' }]) + expect(out).toEqual([{ start: 4, end: 11, trigger: '@' }]) }) it('names off the lexicon do not match; triggers are routed per lexicon list', () => { diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index d7772e006a..2603c648b3 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/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-goal/README.md -README.md: 29d1c0930869232527cdbfbb091f3d2126db68e9 -README.zh.md: 45f5334ef3988d5c64e0707902609a2b21d46368 +README.md: 05c44fee27f0500c6a5d53496fde3d447edbf424 +README.zh.md: f280356eb37b364f49851ca24d73370eb62e6795 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 29d1c09308..05c44fee27 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -29,7 +29,7 @@ Mount this plugin alongside `ui-conversation` and the goal domain package; the s ### The command-input bubble -Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `命令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. +Each durable `/goal` run projects as a right-aligned monospace user-style bubble labeled `Command input` (or `指令输入`), rendered before the generic command result row. It carries no timestamp, copy, or branch actions, and reloading reconstructs it from the run. ### Failures diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 45f5334ef3..f280356eb3 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -27,9 +27,9 @@ kind: "package-reference" 与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。active 的 goal 提供暂停动作;paused 的提供恢复;编辑重写目标文本;清除移除目标,并在投影追上之前抑制条带。 -### 命令输入气泡 +### 指令输入气泡 -每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `命令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 +每条持久的 `/goal` 运行都投影为一个右对齐的等宽用户样式气泡,标签为 `Command input`(或 `指令输入`),渲染在通用命令结果行之前。它不含时间戳、复制或分支操作,重新加载时会依据运行记录重建。 ### 失败 @@ -43,7 +43,7 @@ kind: "package-reference"
实现细节——点击展开 -条带是投影模式:活目标经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新),因此插件不持有领域 store、不设刷新链、不挂事件监听。注入面只携带四个变更动词,经 `ctx.remote.goals` 调用;每个动词在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。命令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。 +条带是投影模式:活目标经 `useProjection('goal')` 到达(由历史尾页播种、`session/projection` 帧更新),因此插件不持有领域 store、不设刷新链、不挂事件监听。注入面只携带四个变更动词,经 `ctx.remote.goals` 调用;每个动词在调用时从会话当前投影值读取 CAS ref,比较并交换(RPC 的 CAS)就是陈旧性护栏。由于 React 的 pending 渲染无法拦住同一帧内的点击,条带会同步为变更建立 single-flight 防护。指令输入投影是独立的 Conversation Definition,在通用命令结果 Node 之前构建 `command-input` Chat Node;它绝不创建 `user/message` 或模型轮次。
diff --git a/packages/client/ui-goal/src/client/locales.ts b/packages/client/ui-goal/src/client/locales.ts index 5fd6411573..055991e277 100644 --- a/packages/client/ui-goal/src/client/locales.ts +++ b/packages/client/ui-goal/src/client/locales.ts @@ -6,7 +6,7 @@ export const zh = { 'phase.paused': '已暂停的目标', 'phase.blocked': '受阻的目标', 'objective.aria': '目标内容', - 'commandInput.aria': '命令输入', + 'commandInput.aria': '指令输入', 'action.save': '保存目标', 'action.cancel': '取消编辑', 'action.pause': '暂停目标', diff --git a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx index d8fc191f84..6c8c1e34cc 100644 --- a/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx +++ b/packages/client/ui-goal/tests/goal-command-input.client.spec.tsx @@ -129,7 +129,7 @@ describe('goal command input projection', () => { t, } as unknown as Parameters[0] const view = render() - const bubble = view.getByRole('group', { name: '命令输入' }) + const bubble = view.getByRole('group', { name: '指令输入' }) expect(bubble.textContent).toBe('/goal ship it') expect(within(bubble).queryByRole('button')).toBeNull() diff --git a/packages/client/ui-input-trigger/src/client/MenuView.module.css b/packages/client/ui-input-trigger/src/client/MenuView.module.css index f93bb2b1a5..817831f393 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.module.css +++ b/packages/client/ui-input-trigger/src/client/MenuView.module.css @@ -1,13 +1,11 @@ .menu { position: absolute; bottom: calc(100% + 4px); + /* The overlay anchor is exactly the composer card's width; both insets pin + the menu edge to edge with border and padding kept inside. */ left: 0; + right: 0; z-index: 100; - min-width: min(260px, 100%); - /* 537 is the design cap; the 100% clamp keeps the menu inside the composer - card when a narrow viewport shrinks the card below the cap (the overlay - anchor is exactly the card's width). */ - max-width: min(537px, 100%); /* Height cap: the 320px design maximum, clamped at runtime to the space * above the composer (inline max-height set in MenuView.tsx). */ max-height: 320px; @@ -49,7 +47,8 @@ text-align: left; } -.item:hover, +/* One shared highlight: pointer motion parks it here through the hover event, + so there is no separate :hover tint competing with the keyboard's. */ .item.active { background: var(--dsw-alias-interactive-bg-hover); } @@ -95,19 +94,54 @@ color: var(--dsw-alias-label-tertiary); } -/* Trailing descent affordance on drillable rows (directories): a quiet - chevron that brightens on its own hover, separate from the row pick. */ +/* Trailing seat on drillable rows (directories): the Tab keycap hint plus the + drill chevron, revealed while the row holds the shared highlight. */ +.trailing { + flex: none; + display: inline-flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +/* Hint naming the keyboard twin of the chevron — a caption line plus a Tab + keycap: Tab drills the highlighted row. Hidden at rest so idle rows stay + quiet. */ +.drillHintText { + display: none; + color: var(--dsw-alias-label-caption); + font-size: 11px; + line-height: 18px; + white-space: nowrap; +} + +.drillHint { + display: none; + padding: 0 5px; + border-radius: 4px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-caption); + font-family: inherit; + font-size: 11px; + line-height: 18px; +} + +.item.active .drillHintText, +.item.active .drillHint { + display: inline-flex; +} + +/* Descent affordance: a quiet chevron that brightens on its own hover, + separate from the row pick. */ .drill { flex: none; display: inline-grid; place-items: center; width: 20px; height: 20px; - margin-left: auto; border-radius: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 14px; - line-height: 1; + /* label-caption, the quiet tint the composer's access-mode chevron uses. */ + color: var(--dsw-alias-label-caption); } .drill:hover { @@ -124,13 +158,28 @@ color: var(--dsw-alias-label-tertiary); } -/* Pending-source row: same cell metrics, dimmed label. */ -.loading { +/* Pending-source skeleton rows: item cell metrics around a breathing bar + (deepsuite Skeleton text variant: 2s color pulse; here the single + bg-skeleton token pulses through opacity instead of a second color). */ +.skeletonRow { display: flex; align-items: center; + /* border-box so min-height matches the .item buttons, whose UA default + box-sizing already counts the same 8px paddings inside their 40px. */ + box-sizing: border-box; min-height: 40px; padding: 8px 10px; - font-size: 14px; - line-height: 22px; - color: var(--dsw-alias-label-dimmed); +} + +.skeletonBar { + height: 20px; + border-radius: 4px; + background: var(--dsw-alias-bg-skeleton); + animation: dsh-menu-skeleton 2s cubic-bezier(0.36, 0, 0.64, 1) infinite; +} + +@keyframes dsh-menu-skeleton { + 0% { opacity: 1; } + 40% { opacity: 0.6; } + 80%, 100% { opacity: 1; } } diff --git a/packages/client/ui-input-trigger/src/client/MenuView.tsx b/packages/client/ui-input-trigger/src/client/MenuView.tsx index 543f76eda8..3369874e77 100644 --- a/packages/client/ui-input-trigger/src/client/MenuView.tsx +++ b/packages/client/ui-input-trigger/src/client/MenuView.tsx @@ -2,14 +2,14 @@ * Trigger candidate menu: renders the InputTriggerService menu store into the * conversation.input.overlay anchor. Closed state renders null (the overlay * slot stays mounted); groups render in roster order under localized title - * rows, pending groups as a loading row; pointer picks route back through + * rows, pending groups as two skeleton rows; pointer picks route back through * the service (combobox pattern — focus never leaves the textarea, so rows * are mousedown-handled and the highlight is exposed via * aria-activedescendant on the listbox). */ import { Fragment, useEffect, useRef, useSyncExternalStore } from 'react' import clsx from 'clsx' -import { useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconChevronRightOutline14, ReferenceIcon, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import css from './MenuView.module.css' import type { MenuViewInjected } from './slots.ts' @@ -31,7 +31,7 @@ function optionId(source: string, index: number): string { * @param props - injected face (the menu store and the pick route); `t` rides the standard locale seat. * @returns the dropdown while open; null while closed. */ -export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { +export function MenuView({ menu, onPick, onHover, onDismiss, t }: MenuViewProps) { const state = useSyncExternalStore( fn => menu.subscribe(fn), () => menu.getSnapshot(), @@ -85,7 +85,12 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { ? null :
{t(group.source as MenuKey)}
} {group.status === 'pending' - ?
{t('loading')}
+ ? ( +
+
+
+
+ ) : group.items.map((item, index) => { const active = highlight !== null && highlight.source === group.source && highlight.index === index return ( @@ -106,24 +111,38 @@ export function MenuView({ menu, onPick, onDismiss, t }: MenuViewProps) { ev.preventDefault() onPick(group.source, index) }} + // mousemove, not mouseenter: real pointer motion moves the + // shared highlight; keyboard scrolling rows under a resting + // pointer must not steal it back. + onMouseMove={active ? undefined : () => { onHover(group.source, index) }} > - {item.icon !== undefined && {item.icon}} + {item.icon !== undefined && ( + + + + )} {item.name} {item.description !== undefined && {item.description}} {item.drill === true && ( - { - ev.preventDefault() - ev.stopPropagation() - onPick(group.source, index, 'drill') - }} - > - › + + {/* Visual hint only: Tab drills the highlighted row (the + keyboard twin of the chevron, which owns the aria label). */} + {t('drill.hint')} + {t('drill.key')} + { + ev.preventDefault() + ev.stopPropagation() + onPick(group.source, index, 'drill') + }} + > + + )} diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 71827214bd..b69d423213 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -178,6 +178,18 @@ export class InputTriggerController { this.execute(outcome, hit.span) } + /** + * Pointer hover from MenuView: park the shared highlight on the hovered + * candidate (keyboard `move` and pointer hover drive one highlight — + * last input wins). + * @param source - source (group) name. + * @param index - candidate index within the group. + */ + hover(source: string, index: number): void { + if (this.disposed) return + this.reduce({ type: 'hover', source, index }) + } + /** * Keyboard arbitration while the menu is open. * @param key - intercepted key. diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index c79f3d010d..4c586bc7e0 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -75,6 +75,7 @@ export function apply(ctx: ClientContext): void { return { menu: controller.menu, onPick: (source, index, action) => { controller.pick(source, index, action) }, + onHover: (source, index) => { controller.hover(source, index) }, onDismiss: () => { controller.dismiss() }, } }, diff --git a/packages/client/ui-input-trigger/src/client/locales.ts b/packages/client/ui-input-trigger/src/client/locales.ts index f8d5dab806..4f689a3a3d 100644 --- a/packages/client/ui-input-trigger/src/client/locales.ts +++ b/packages/client/ui-input-trigger/src/client/locales.ts @@ -6,11 +6,13 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { - 'command': '命令', + 'command': '指令', 'skill': '技能', 'subagent': '子智能体', 'loading': '正在加载…', 'drill.aria': '进入目录', + 'drill.hint': '进入目录', + 'drill.key': 'Tab', 'suggestions.aria': '触发候选建议', } satisfies Record @@ -24,5 +26,7 @@ export const en = { 'subagent': 'Subagents', 'loading': 'Loading…', 'drill.aria': 'Browse folder', + 'drill.hint': 'Browse folder', + 'drill.key': 'Tab', 'suggestions.aria': 'Trigger suggestions', } satisfies Record diff --git a/packages/client/ui-input-trigger/src/client/slots.ts b/packages/client/ui-input-trigger/src/client/slots.ts index 85265f8d72..93dad04f03 100644 --- a/packages/client/ui-input-trigger/src/client/slots.ts +++ b/packages/client/ui-input-trigger/src/client/slots.ts @@ -15,6 +15,13 @@ export interface MenuViewInjected { * @param action - settling pick (default) or the candidate's drill action. */ onPick: (source: string, index: number, action?: PickAction) => void + /** + * Pointer hover routed to the shared highlight (pointer and keyboard drive + * one highlight — last input wins). + * @param source - source (group) name. + * @param index - candidate index within the group. + */ + onHover: (source: string, index: number) => void /** Dismiss the menu (external pointer outside the composer area). */ onDismiss: () => void } diff --git a/packages/client/ui-input-trigger/src/core/contract.ts b/packages/client/ui-input-trigger/src/core/contract.ts index 685364f489..eab79f1066 100644 --- a/packages/client/ui-input-trigger/src/core/contract.ts +++ b/packages/client/ui-input-trigger/src/core/contract.ts @@ -51,6 +51,7 @@ export type MenuEvent = | { readonly type: 'source-settled'; readonly generation: number; readonly source: string; readonly items?: readonly InputTriggerCandidate[] } | { readonly type: 'source-failed'; readonly generation: number; readonly source: string } | { readonly type: 'move'; readonly dir: 1 | -1 } + | { readonly type: 'hover'; readonly source: string; readonly index: number } | { readonly type: 'close' } /** Pure menu reducer; returns the same reference when the event is stale or a no-op. */ diff --git a/packages/client/ui-input-trigger/src/core/menu.ts b/packages/client/ui-input-trigger/src/core/menu.ts index 3fe40ee18c..47a6090d8f 100644 --- a/packages/client/ui-input-trigger/src/core/menu.ts +++ b/packages/client/ui-input-trigger/src/core/menu.ts @@ -81,7 +81,8 @@ const allReadyEmpty = (groups: MenuState['groups']): boolean => * open menu, or the roster is dropped; a settlement or failure leaving every * group ready-and-empty (or no groups) auto-closes; `source-failed` silently * removes the group (the shell logs); `move` cycles the highlight across - * ready items. + * ready items; `hover` parks it on one ready item (pointer and keyboard + * share the single highlight — last input wins). * * @param state - Current menu state. * @param ev - Menu event. @@ -131,6 +132,14 @@ export const menuReduce: MenuReduce = (state, ev) => { if (hl && next.source === hl.source && next.index === hl.index) return state return { ...state, highlight: next } } + case 'hover': { + if (!state.open) return state + const target = validHighlight({ source: ev.source, index: ev.index }, state.groups) + if (target === null) return state + const hl = state.highlight + if (hl && hl.source === target.source && hl.index === target.index) return state + return { ...state, highlight: target } + } case 'close': return closed(state) } diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1f8f3658e2..1abde9de66 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -40,11 +40,14 @@ export type PickVia = 'menu' | 'space' | 'enter' /** What a pick asks for: resolve the candidate, or drill into it in place. */ export type PickAction = 'pick' | 'drill' +/** Leading glyph token of one menu candidate, mapped to its SVG by the menu view. */ +export type InputTriggerCandidateIcon = 'file' | 'folder' | 'session' + /** One menu candidate. Pure display data — zero behavior declaration. */ export interface InputTriggerCandidate { readonly name: string readonly description?: string - readonly icon?: string + readonly icon?: InputTriggerCandidateIcon readonly hint?: string /** Optional visual heading shared by adjacent candidates; sectioned groups omit their source-title row. */ readonly section?: string diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 9cf41c62a1..182252ee38 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -50,7 +50,7 @@ describe('apply', () => { const { ctx, locale } = await bench() await ctx.plugin({ inject: [...inject], apply }).await() const t = locale.bind('slash.menu') - expect(t('command')).toBe('命令') + expect(t('command')).toBe('指令') locale.setLocale('en') expect(t('skill')).toBe('Skills') expect(t('subagent')).toBe('Subagents') diff --git a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts index a849a7c69d..532f608fcf 100644 --- a/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/core-menu.client.spec.ts @@ -16,6 +16,14 @@ function open(sources: readonly string[], h: TriggerHit = hit()): MenuState { const item = (name: string) => ({ name }) +/** Two ready groups: command [goal, model], skill [commit]. */ +function ready(): MenuState { + let s = open(['command', 'skill']) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] }) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] }) + return s +} + describe('menuReduce hit', () => { it('opens a new generation with all groups pending', () => { const s = open(['command', 'skill']) @@ -147,14 +155,6 @@ describe('menuReduce source-failed', () => { }) describe('menuReduce move', () => { - /** Two ready groups: command [goal, model], skill [commit]. */ - function ready(): MenuState { - let s = open(['command', 'skill']) - s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal'), item('model')] }) - s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'skill', items: [item('commit')] }) - return s - } - it('cycles forward across groups and wraps', () => { let s = ready() s = menuReduce(s, { type: 'move', dir: 1 }) @@ -195,6 +195,28 @@ describe('menuReduce move', () => { }) }) +describe('menuReduce hover', () => { + it('parks the highlight on the hovered ready item', () => { + const s = menuReduce(ready(), { type: 'hover', source: 'skill', index: 0 }) + expect(s.highlight).toEqual({ source: 'skill', index: 0 }) + }) + + it('is a no-op reference when closed, on invalid targets, and on the current highlight', () => { + const closed = menuReduce(ready(), { type: 'close' }) + expect(menuReduce(closed, { type: 'hover', source: 'command', index: 0 })).toBe(closed) + const s = ready() + expect(menuReduce(s, { type: 'hover', source: 'ghost', index: 0 })).toBe(s) + expect(menuReduce(s, { type: 'hover', source: 'command', index: 5 })).toBe(s) + expect(menuReduce(s, { type: 'hover', source: 'command', index: 0 })).toBe(s) + }) + + it('ignores a hover into a still-pending group', () => { + let s = open(['command', 'skill']) + s = menuReduce(s, { type: 'source-settled', generation: 1, source: 'command', items: [item('goal')] }) + expect(menuReduce(s, { type: 'hover', source: 'skill', index: 0 })).toBe(s) + }) +}) + describe('menuReduce close', () => { it('clears everything but keeps the generation for stale-drop', () => { let s = open(['command']) diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index c9bf518b08..48b5d52136 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -2,10 +2,10 @@ /** * MenuView rendering spec, props-direct: closed store * renders null, groups render in roster order under localized title rows - * (unknown sources fall back to the raw name) with pending rows as loading, - * pointer picks route (source, index) back without stealing focus, the - * highlight is exposed through aria-activedescendant + aria-selected, and - * the list height clamps to the space above the composer. + * (unknown sources fall back to the raw name) with pending rows as skeleton + * placeholders, pointer picks route (source, index) back without stealing + * focus, the highlight is exposed through aria-activedescendant + + * aria-selected, and the list height clamps to the space above the composer. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' @@ -32,7 +32,7 @@ function openState(partial?: Partial): MenuState { hit, generation: 1, groups: [ - { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: '⚑' }, { name: 'plan' }] }, + { source: 'command', status: 'ready', items: [{ name: 'goal', description: 'Set up a goal', icon: 'file' }, { name: 'plan' }] }, { source: 'skill', status: 'pending', items: [] }, ], highlight: { source: 'command', index: 0 }, @@ -60,9 +60,10 @@ const t = makeTranslate(zh, commonZh) function mount(state: MenuState) { const menu = createSnapshotStore(state) const onPick = vi.fn() + const onHover = vi.fn() const onDismiss = vi.fn() - const view = render() - return { menu, onPick, onDismiss, view } + const view = render() + return { menu, onPick, onHover, onDismiss, view } } /** The non-interactive group title rows (role=presentation), in document order. */ @@ -81,11 +82,15 @@ describe('MenuView', () => { expect(view.container.childElementCount).toBe(0) }) - it('renders ready groups as option rows and pending groups as loading rows', () => { + it('renders ready groups as option rows and pending groups as two skeleton rows', () => { mount(openState()) const options = screen.getAllByRole('option') - expect(options.map(o => o.textContent)).toEqual(['⚑goalSet up a goal', 'plan']) - expect(screen.queryByText('正在加载…')).not.toBeNull() + expect(options.map(o => o.textContent)).toEqual(['goalSet up a goal', 'plan']) + // The icon token renders as an SVG glyph, not text. + expect(options[0]?.querySelector('svg')).not.toBeNull() + expect(options[1]?.querySelector('svg')).toBeNull() + const status = screen.getByRole('status', { name: '正在加载…' }) + expect(status.children).toHaveLength(2) }) it('keeps an opted-out source title hidden while its candidates are pending', () => { @@ -94,7 +99,7 @@ describe('MenuView', () => { highlight: null, })) expect(screen.queryByText('reference')).toBeNull() - expect(screen.getByText('正在加载…')).toBeTruthy() + expect(screen.getByRole('status', { name: '正在加载…' })).toBeTruthy() }) it('titles each group with the localized source name, raw name for unknown sources, none for empty ready groups', () => { @@ -106,7 +111,7 @@ describe('MenuView', () => { { source: 'skill', status: 'pending', items: [] }, ], })) - expect(titles(view.container)).toEqual(['命令', 'mystery', '技能']) + expect(titles(view.container)).toEqual(['指令', 'mystery', '技能']) }) it('renders contiguous candidate sections once without changing option indexes', () => { @@ -117,14 +122,14 @@ describe('MenuView', () => { items: [ { name: 'Folder · src/', section: '文件与文件夹' }, { name: 'File · README.md', section: '文件与文件夹' }, - { name: 'Session · Research', section: 'Session 对话' }, + { name: 'Session · Research', section: '对话' }, ], }], highlight: { source: 'reference', index: 0 }, })) expect(screen.queryByText('reference')).toBeNull() expect(screen.getAllByText('文件与文件夹')).toHaveLength(1) - expect(screen.getAllByText('Session 对话')).toHaveLength(1) + expect(screen.getAllByText('对话')).toHaveLength(1) const options = screen.getAllByRole('option') expect(options.map(option => option.textContent)).toEqual([ 'Folder · src/', @@ -219,7 +224,7 @@ describe('MenuView', () => { const onDismiss = vi.fn() render(
- +
, ) @@ -252,4 +257,15 @@ describe('MenuView', () => { expect(notPrevented).toBe(false) expect(onPick).toHaveBeenCalledWith('command', 1) }) + + it('pointer motion over a row routes hover; the highlighted row stays silent', () => { + const { onHover } = mount(openState()) + const options = screen.getAllByRole('option') + fireEvent.mouseMove(options[1]!) + expect(onHover).toHaveBeenCalledWith('command', 1) + onHover.mockClear() + // Index 0 already holds the highlight: no hover round-trip. + fireEvent.mouseMove(options[0]!) + expect(onHover).not.toHaveBeenCalled() + }) }) diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index b494abf478..b6849cfb4c 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -700,6 +700,18 @@ describe('arbitrate', () => { expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) }) + it('hover parks the shared highlight; disposed controllers ignore it', async () => { + const { controller } = await menuBench() + controller.hover('command', 1) + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 1 }) + // Keyboard keeps moving from the parked spot: last input wins. + expect(controller.arbitrate('up', false)).toBe('consumed') + expect(controller.menu.getSnapshot().highlight).toEqual({ source: 'command', index: 0 }) + controller.dispose() + controller.hover('command', 1) + expect(controller.menu.getSnapshot().highlight).toBeNull() + }) + it('enter picks the highlight through the pipeline', async () => { const { controller, cmd } = await menuBench() expect(controller.arbitrate('enter', false)).toBe('pick-highlighted') diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 7b87af0e1f..392fe365f8 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -111,8 +111,9 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean mention, } return [{ - name: `${t(directory ? 'candidate.folder' : 'candidate.file')} · ${name}${directory ? '/' : ''}`, + name: `${name}${directory ? '/' : ''}`, description: candidate.path, + icon: directory ? 'folder' as const : 'file' as const, section: t('section.files'), value: JSON.stringify(value), ...(directory ? { drill: true } : {}), @@ -128,8 +129,9 @@ function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Transl mention: candidate.mention, } return { - name: `${t('candidate.session')} · ${candidate.label}`, + name: candidate.label, description, + icon: 'session' as const, section: t('section.sessions'), value: JSON.stringify(value), } diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index 41ec525e75..e0885f9f3c 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -8,10 +8,7 @@ export const NS = 'reference' /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { 'section.files': '文件与文件夹', - 'section.sessions': 'Session 对话', - 'candidate.file': '文件', - 'candidate.folder': '文件夹', - 'candidate.session': 'Session', + 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', } satisfies Record @@ -28,9 +25,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** English dictionary, checked complete against the zh key set. */ export const en = { 'section.files': 'Files & folders', - 'section.sessions': 'Session conversations', - 'candidate.file': 'File', - 'candidate.folder': 'Folder', - 'candidate.session': 'Session', + 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', } satisfies Record diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 5b782521e0..bfb6b919dc 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -167,19 +167,22 @@ describe('candidates', () => { releaseFiles() await expect(pending).resolves.toEqual([ expect.objectContaining({ - name: 'Folder · src/', + name: 'src/', description: 'src', + icon: 'folder', section: 'Files & folders', }), expect.objectContaining({ - name: 'File · a b.md', + name: 'a b.md', description: 'docs/a b.md', + icon: 'file', section: 'Files & folders', }), expect.objectContaining({ - name: 'Session · Research', + name: 'Research', description: 'source · /project · 2023-11-14T22:13:20.000Z', - section: 'Session conversations', + icon: 'session', + section: 'Sessions', }), ]) }) @@ -203,7 +206,7 @@ describe('candidates', () => { })) const { source } = await bench(files, sessions) const quoted = await source.candidates(session, request('READ', { quoted: true })) - expect(quoted).toEqual([expect.objectContaining({ name: 'File · README.md' })]) + expect(quoted).toEqual([expect.objectContaining({ name: 'README.md', icon: 'file' })]) expect(source.onPick({ candidate: quoted[0]!, session, @@ -222,7 +225,7 @@ describe('candidates', () => { }) expect(sessions).not.toHaveBeenCalled() await expect(source.candidates(session, request('research'))).resolves.toEqual([ - expect.objectContaining({ name: 'Session · Research' }), + expect.objectContaining({ name: 'Research', icon: 'session' }), ]) }) @@ -269,7 +272,7 @@ describe('candidates', () => { const { source } = await bench(files, sessions) await expect(source.candidates(session, request('same'))).resolves.toEqual([ expect.objectContaining({ - name: 'Session · same', + name: 'same', description: '(no cwd) · 1970-01-01T00:00:00.000Z', }), ]) @@ -320,7 +323,7 @@ describe('pick and codec', () => { it('inserts sessions as atomic chips whose clipboard and model forms are canonical mentions', async () => { const { source } = await bench() const candidates = await source.candidates(session, request('')) - const candidate = candidates.find(item => item.name === 'Session · Research')! + const candidate = candidates.find(item => item.name === 'Research')! const mention = '@[Research](dsh-session:InNvdXJjZSI)' expect(pick(source, candidate)).toEqual({ insert: { diff --git a/snapshots/web/approval-composer/session.jsonl b/snapshots/web/approval-composer/session.jsonl index 5b3c8cfdd1..05b8474f65 100644 --- a/snapshots/web/approval-composer/session.jsonl +++ b/snapshots/web/approval-composer/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787528546562,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736494132,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index f4b07c037f..e633f1ca1c 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -26,7 +26,7 @@ - 'button "Failed Bash Error: tool call aborted before dispatch"': - img - text: "Failed Bash Error: tool call aborted before dispatch" -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Full access"': Full access diff --git a/snapshots/web/code-mode-round/session.jsonl b/snapshots/web/code-mode-round/session.jsonl index bb4ae42caa..f8b3b161a3 100644 --- a/snapshots/web/code-mode-round/session.jsonl +++ b/snapshots/web/code-mode-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787628995177,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736414061,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[17,17,17,15,16,16,17,17,17,17,16,17,17,17,16,16,17,17,15,16,15,17,17,17,16,17,16,16,17,17,15,16,17,16,17,16,15,17,17,15,16,17,17,15,17,15,16,16,16,16,15,16,16,17,17,17,16,16,17,17,17,16,15,17,16,16,16,16,16,17,16,16,16,16],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[15,16,17,16,16,16,16,16,17,17,17,16,16,17,16,16,17,17,17,16,17,16,16,17,16,17,17,16,16,17,16,17,17,17,16,18,19,17,17,19,23,16,17,16,17,17,16,17,15,17,17,17,17,16,16,17,17,16,16,17,15,17,17,16,16,16,17,16,17,16,17,17,16,17],"texts":["The"," user"," wants"," me"," to"," write"," a"," single"," `","run","_code","`"," program"," that",":\n","1","."," Runs"," bash"," to"," echo"," \"","CODE","_RO","UND","_OK","\"\n","2","."," T","ries"," to"," read"," a"," file"," \"","missing",".txt","\""," and"," catches"," the"," error","\n","3","."," Returns"," an"," object"," with"," both"," outcomes","\n","4","."," They"," also"," want"," me"," to"," reply"," \"","D","ONE","\""," and"," stop"," after","\n\n","Let"," me"," write"," this"," program","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[15,16,15,16,16,16,17,17,17,16,16,16,16,16,16,16,16,16,15,16,16,17,15,16,16,16,15,16,16,16,16,16,16,16,16,15,16,17,16,16,16,16,15,15,16,16,16,16,17,16,17,17,15,16,17,17,16,15,16,17,17,15,16,15,16,16,16,15,16,16,16,17,16,16,16,17,17,16,16,16,16,16,17,17,17,17,17,16,16,17,17,16,16,16,16,16,17,15,15,16,15,17,17,15,16,16,16,17,16,14,17,17,17,17,15,16,16,15],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[17,17,16,16,17,16,17,17,16,17,17,15,17,16,17,16,17,17,17,17,16,17,16,17,16,16,16,17,16,17,16,17,17,17,16,17,16,16,17,16,16,16,17,16,17,15,16,17,16,16,17,16,16,17,15,18,16,17,16,16,17,16,17,17,16,17,16,16,17,16,17,16,17,16,17,16,17,17,16,16,17,16,17,16,17,17,16,16,17,15,17,16,15,17,16,16,16,17,16,17,17,16,17,16,17,19,17,17,16,18,15,17,16,15,17,16,16,17],"id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","args":["","{","\"","description","\"",": ","\"","Run"," bash"," echo"," and"," catch"," missing"," file"," read","\"",", ","\"","code","\"",": ","\"","\\n","const"," bash","Result"," ="," await"," tools",".b","ash","({\\n"," "," command",":"," \\\"","echo"," CODE","_RO","UND","_OK","\\\",\\n"," "," description",":"," \\\"","E","cho"," CODE","_RO","UND","_OK","\\\"\\n","});\\n\\n","let"," read","Error"," ="," null",";\\n","try"," {\\n"," "," await"," tools",".read","({"," file","_path",":"," \\\"","missing",".txt","\\\""," });\\n","}"," catch"," (","e",")"," {\\n"," "," read","Error"," ="," {\\n"," "," tool","Name",":"," e",".t","ool","Name",",\\n"," "," message",":"," e",".message","\\n"," "," };\\n","}\\n\\n","return"," {"," bash",":"," bash","Result",".stdout",".text",".trim","(),"," read","Error"," };\\n","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to write a single `run_code` program that:\n1. Runs bash to echo \"CODE_ROUND_OK\"\n2. Tries to read a file \"missing.txt\" and catches the error\n3. Returns an object with both outcomes\n4. They also want me to reply \"DONE\" and stop after\n\nLet me write this program."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_6VNoF1gDSerTBKoCfYSH3765","name":"run_code","arguments":"{\"description\": \"Run bash echo and catch missing file read\", \"code\": \"\\nconst bashResult = await tools.bash({\\n command: \\\"echo CODE_ROUND_OK\\\",\\n description: \\\"Echo CODE_ROUND_OK\\\"\\n});\\n\\nlet readError = null;\\ntry {\\n await tools.read({ file_path: \\\"missing.txt\\\" });\\n} catch (e) {\\n readError = {\\n toolName: e.toolName,\\n message: e.message\\n };\\n}\\n\\nreturn { bash: bashResult.stdout.text.trim(), readError };\\n\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":8082,"outputTokens":235,"cacheReadTokens":384,"reasoningTokens":75}}}} @@ -29,7 +29,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,16,16,16,17,15,16,15,16,16,15,16],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,17,16,17,16,16,16,16,17,17,15,16],"texts":["The"," program"," ran"," successfully","."," Let"," me"," now"," reply"," D","ONE"," as"," instructed","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md index bcdd6b6c5d..8df10012df 100644 --- a/snapshots/web/code-mode-round/ui.expected.md +++ b/snapshots/web/code-mode-round/ui.expected.md @@ -47,7 +47,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/cordis-tool-round/session.jsonl b/snapshots/web/cordis-tool-round/session.jsonl index 3ca3849135..354033a43e 100644 --- a/snapshots/web/cordis-tool-round/session.jsonl +++ b/snapshots/web/cordis-tool-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530430299,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736473857,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 286cd191b5..0fa28bef4b 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -100,7 +100,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index df302f69ed..f654d948bf 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -38,7 +38,7 @@ - img - img - text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled." -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/feedback-command/session.jsonl b/snapshots/web/feedback-command/session.jsonl index 322863bd92..66f07aa443 100644 --- a/snapshots/web/feedback-command/session.jsonl +++ b/snapshots/web/feedback-command/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520609412,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736480169,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,7 +12,7 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0],"texts":["L","IGH","TH","O","USE"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}} @@ -24,4 +24,4 @@ {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}} {"type":"feedback/record","data":{"text":"the diff view is unreadable"}} -{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{session:1}}\nAnonymous user: {{rpc:1}}. Session sharing is enabled."}} +{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{session:1}}\nAnonymous user: {{anonymousUserId}}. Session sharing is enabled."}} diff --git a/snapshots/web/fresh-round-trip/session.jsonl b/snapshots/web/fresh-round-trip/session.jsonl index eeb83c93c3..932a747a85 100644 --- a/snapshots/web/fresh-round-trip/session.jsonl +++ b/snapshots/web/fresh-round-trip/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520129674,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736463867,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,15,17,16,18,18,17,16,18,17,16,18,16,14,17,16],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,15,16,15,17,16,16,15,17,16,16,15,16,17,17,16],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," reply"," with"," \"","D","ONE","\"."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[17,18,17,15,17,17,16,17,16,17,17,16,18,16,18,15,17,16,17,15,17,14,17,18,18,18],"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," WEB","_E","2","E","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," the"," test"," string","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[16,17,16,16,16,17,16,16,16,16,16,17,17,18,15,17,16,16,17,16,15,16,16,16,16,17],"id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," WEB","_E","2","E","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," the"," test"," string","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BYXlxjFaalMg95YVqEeF2495","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Echo the test string\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":122,"outputTokens":85,"cacheReadTokens":7680,"reasoningTokens":17}}}} @@ -25,7 +25,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,18,15,17,17,17,17,15,17,17,16,17,18,17,15,17,17,16,17,17,17],"texts":["The"," command"," executed"," successfully"," and"," output"," \"","WEB","_E","2","E","_OK","\"."," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,16,17,17,17,17,16,16,17,16,17,17,16,17,16,16,16,17,16,17,16],"texts":["The"," command"," executed"," successfully"," and"," output"," \"","WEB","_E","2","E","_OK","\"."," I"," just"," need"," to"," reply"," with"," \"","D","ONE","\"."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index c7822c503d..24717b214d 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -42,7 +42,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/goal-multi-turn-actions/session.jsonl b/snapshots/web/goal-multi-turn-actions/session.jsonl index 78d92f9a19..436768541a 100644 --- a/snapshots/web/goal-multi-turn-actions/session.jsonl +++ b/snapshots/web/goal-multi-turn-actions/session.jsonl @@ -1,9 +1,9 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787640083383,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736496504,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} {"type":"command/run","data":{"commandId":"{{command:1}}","name":"goal","args":" 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","source":{"kind":"user"}}} -{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1787640083556,"updatedAt":1787640083556}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"create","goal":{"id":"{{id:1}}","revision":1,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"active","maxGoalRounds":256},"roundsStarted":0,"createdAt":1787736496698,"updatedAt":1787736496698}} {"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Goal created\nStatus: active\nObjective: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\nRounds: 0/256\nActivation: armed\n\nCommands: /goal edit , /goal pause, /goal clear"}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nRound: 1/256\n\nContinue working toward the objective in this same session. Treat the current workspace, tool results, and durable session state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal, and mark it complete. If work remains, leave the goal active for the next round. Follow the configured goal-tool policy before reporting a blocker.\n"}],"source":{"kind":"goal","goalId":"{{id:1}}","revision":1,"round":1},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} @@ -84,9 +84,9 @@ {"type":"step/end","data":{"turn":1,"step":5}} {"type":"step/start","data":{"turn":1,"step":6}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":6,"index":0,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["Turn"," ","1"," is"," done","."," Per"," the"," objective",":"," \"","你","做完","一个","turn","之后",",","直接","输出","内容",",","停止","\""," —"," after"," finishing"," a"," turn",","," directly"," output"," the"," content"," and"," stop","."," The"," system"," will"," open"," another"," turn",".\n\n","So"," I"," should"," just"," output"," the"," file"," structure"," of"," this"," randomly"," picked"," package"," (","pack","ages","/","context","/s","ession","-reference",")"," and"," stop","."," I"," should"," NOT"," mark"," the"," goal"," complete"," since"," there"," are"," ","2"," turns"," and"," this"," is"," only"," turn"," ","1","."," The"," objective"," says"," the"," system"," will"," open"," another"," turn"," —"," so"," I"," should"," leave"," the"," goal"," active"," for"," the"," next"," round",".\n\n","Let"," me"," output"," the"," file"," structure"," in"," a"," nice"," tree"," format","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":6,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} +{"type":"text-chunks","data":{"turn":1,"step":6,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0],"texts":["**","Turn"," ","1"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","context","/s","ession","-reference","`"," 📦","\n\n","```\n","session","-reference","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," ├","──"," index",".ts","\n","│"," "," └","──"," uri",".ts","\n","└","──"," tests","\n"," "," └","──"," session","-reference",".s","pec",".ts","\n","```\n\n","```\n","session","-reference","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," ├","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","\n","│"," "," └","──"," uri",".ts"," "," #"," ","会话","引用"," URI"," ","相关","实现","\n","└","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","这是一个","很","典型的","轻","量"," Type","Script"," ","包","结构",":","入口"," +"," ","实现"," +"," ","测试","。","这一","轮","到此","结束",",","等","系统","开启","下一个"," turn","。"]}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Turn 1 is done. Per the objective: \"你做完一个turn之后,直接输出内容,停止\" — after finishing a turn, directly output the content and stop. The system will open another turn.\n\nSo I should just output the file structure of this randomly picked package (packages/context/session-reference) and stop. I should NOT mark the goal complete since there are 2 turns and this is only turn 1. The objective says the system will open another turn — so I should leave the goal active for the next round.\n\nLet me output the file structure in a nice tree format."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"**Turn 1 / 2** — 随机选中的包:`packages/context/session-reference` 📦\n\n```\nsession-reference\n├── README.md\n├── package.json\n├── src\n│ ├── index.ts\n│ └── uri.ts\n└── tests\n └── session-reference.spec.ts\n```\n\n```\nsession-reference/\n├── README.md # 包说明文档\n├── package.json # 包元数据与依赖声明\n├── src/\n│ ├── index.ts # 入口,导出公共 API\n│ └── uri.ts # 会话引用 URI 相关实现\n└── tests/\n └── session-reference.spec.ts # 单元测试\n```\n\n这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":166,"outputTokens":318,"cacheReadTokens":9088,"reasoningTokens":123}}}} @@ -137,9 +137,9 @@ {"type":"step/end","data":{"turn":2,"step":3}} {"type":"step/start","data":{"turn":2,"step":4}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":2,"step":4,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} +{"type":"reasoning-chunks","data":{"turn":2,"step":4,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["This"," is"," turn"," ","2"," of"," ","2","."," I","'ve"," output"," the"," file"," structure"," for"," `","pack","ages","/","ll","m","/t","oken","-meter","`."," Both"," turns"," are"," done","."," I"," should"," output"," the"," content",","," stop",","," and"," then"," mark"," the"," goal"," as"," complete"," since"," both"," turns"," are"," finished","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":4,"index":1,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} +{"type":"text-chunks","data":{"turn":2,"step":4,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["**","Turn"," ","2"," /"," ","2","**"," —"," ","随机","选","中的","包",":","`","pack","ages","/","ll","m","/t","oken","-meter","`"," 📦","\n\n","```\n","token","-meter","\n","├","──"," READ","ME",".md","\n","├","──"," package",".json","\n","├","──"," src","\n","│"," "," └","──"," index",".ts","\n","└","──"," tests","\n"," "," └","──"," token","-meter",".s","pec",".ts","\n","```\n\n","```\n","token","-meter","/\n","├","──"," READ","ME",".md"," "," #"," ","包","说明","文档","\n","├","──"," package",".json"," "," #"," ","包","元","数据","与","依赖","声明","\n","├","──"," src","/\n","│"," "," └","──"," index",".ts"," "," #"," ","入口",",","导出","公共"," API","(","token"," ","计量","逻辑",")\n","└","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts"," "," #"," ","单元","测试","\n","```\n\n","两个"," turn"," ","都","完成了","(","turn"," ","1",":","`","session","-reference","`",",","turn"," ","2",":","`","token","-meter","`",")。","这一","轮","到此","结束","。"]}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":""}}} {"type":"assistant/chunk","data":{"turn":2,"step":4,"chunk":{"type":"tool-call-delta","index":2,"id":"call_00_AKGba6PMDSJdtJI2ZhAz7514","name":"get_goal","argumentsDelta":"{}"}}} @@ -163,7 +163,7 @@ {"type":"assistant/chunk","data":{"turn":2,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":2,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"两个 turn 均已完成,目标达成,标记 goal 为完成。"},{"type":"tool-call","id":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:24}}"},"usage":{"inputTokens":132,"outputTokens":157,"cacheReadTokens":10368,"reasoningTokens":44}},"sourceEventSeqs":[681,682,683,684,685,686,687,688],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":2,"step":5,"callId":"call_goal_complete","name":"update_goal","arguments":"{\"goal_id\":\"{{id:1}}\",\"revision\":1,\"action\":\"complete\"}"}} -{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1787640083556,"updatedAt":1787640084238}} +{"type":"goal/change","data":{"kind":"goal/change","version":1,"operation":"complete","goal":{"id":"{{id:1}}","revision":2,"objective":"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的","phase":"complete","maxGoalRounds":256},"roundsStarted":2,"createdAt":1787736496698,"updatedAt":1787736497205}} {"type":"tool/result","data":{"turn":2,"step":5,"message":{"source":{"kind":"tool","callId":"call_goal_complete"},"content":[{"type":"tool-result","toolCallId":"call_goal_complete","content":[{"type":"text","text":"{\"goal\":{\"id\":\"{{id:1}}\",\"revision\":2,\"objective\":\"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\",\"phase\":\"complete\",\"roundsStarted\":2,\"maxGoalRounds\":256},\"activation\":\"disarmed\"}"}],"isError":false}],"role":"user","id":"{{message:25}}"}},"sourceEventSeqs":[690],"surfaceOp":"append"} {"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"{{message:26}}"}]}} {"type":"step/end","data":{"turn":2,"step":5}} @@ -171,7 +171,7 @@ {"type":"step/start","data":{"turn":2,"step":6}} {"type":"user/message","data":{"content":[{"type":"text","text":"\nObjective: \"做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的\"\nThe goal is marked complete and this autonomous run is ending. Write the closing message to the user now: state the outcome, summarize what was done and how it was verified, and point to the concrete results (files, commits, or other artifacts). Report only what earlier rounds and tool results in this session actually establish; when a detail is not in the session, say so instead of inventing it. Note anything the user should review or do next. Address the user directly. Do not call any more tools in this run; further work waits for the user's next instruction.\n"}],"source":{"kind":"plugin","plugin":"tool-goal","form":"notice","summary":"complete: 做两个turn,每个turn输出随机一个包的文件结构。注意你做完一个turn之后,直接输出内容,停止,我们的系统会帮你再开一个turn,你看着做一个类似的"},"role":"user","id":"{{message:26}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} +{"type":"text-chunks","data":{"turn":2,"step":6,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["两个"," turn"," ","都","完成了"," ✅","\n\n","**","总结",":","**\n\n","-"," **","Turn"," ","1","**"," —"," ","随机","选中"," `","pack","ages","/","context","/s","ession","-reference","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," session","-reference","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," ├","──"," index",".ts","\n"," "," │"," "," └","──"," uri",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," session","-reference",".s","pec",".ts","\n"," "," ```\n","-"," **","Turn"," ","2","**"," —"," ","随机","选中"," `","pack","ages","/","ll","m","/t","oken","-meter","`",",","输","出了","文件","结构",":\n"," "," ```\n"," "," token","-meter","/\n"," "," ├","──"," READ","ME",".md","\n"," "," ├","──"," package",".json","\n"," "," ├","──"," src","/\n"," "," │"," "," └","──"," index",".ts","\n"," "," └","──"," tests","/\n"," "," └","──"," token","-meter",".s","pec",".ts","\n"," "," ```\n\n","**","验证","方式",":","**"," ","两个","包的","随机","选择","都","通过"," `","python","3"," random",".choice","`"," ","从","工作","区的"," `","pack","ages","/","`"," ","目录","下","实际","枚举","的"," ","4"," ","个","候选","包","中","抽取","(","`","ui","-con","vers","ation","`","、","`","session","-reference","`","、","`","token","-meter","`","、","`","skill","-local","`","),","文件","结构","则","用"," `","find","`"," ","实际","列出","文件","后","整理","输出",",","均已","排除"," `","node","_modules","`"," ","和"," `.","git","`","。","第二次","随机","抽","到了","与"," turn"," ","1"," ","相同的","包",",","为避免","重复","换","抽","了","另一个","。\n\n","**","说明",":","**"," ","本轮","没有","产生","任何","文件","修改","或","提交","——","任务","本身就是","\"","输出","文件","结构","\",","所有","产物","都在","上面的","消息","文本","里","。\n\n","如果你","还想","看","剩下","两个","包","(","`","ui","-con","vers","ation","`","、","`","skill","-local","`",")","的结构",",","或者其他","格式","的输出","(","比如","带","文件","大小的","树","形","图","),","随时","告诉我","。"]}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"两个 turn 都完成了 ✅\n\n**总结:**\n\n- **Turn 1** — 随机选中 `packages/context/session-reference`,输出了文件结构:\n ```\n session-reference/\n ├── README.md\n ├── package.json\n ├── src/\n │ ├── index.ts\n │ └── uri.ts\n └── tests/\n └── session-reference.spec.ts\n ```\n- **Turn 2** — 随机选中 `packages/llm/token-meter`,输出了文件结构:\n ```\n token-meter/\n ├── README.md\n ├── package.json\n ├── src/\n │ └── index.ts\n └── tests/\n └── token-meter.spec.ts\n ```\n\n**验证方式:** 两个包的随机选择都通过 `python3 random.choice` 从工作区的 `packages/` 目录下实际枚举的 4 个候选包中抽取(`ui-conversation`、`session-reference`、`token-meter`、`skill-filesystem`),文件结构则用 `find` 实际列出文件后整理输出,均已排除 `node_modules` 和 `.git`。第二次随机抽到了与 turn 1 相同的包,为避免重复换抽了另一个。\n\n**说明:** 本轮没有产生任何文件修改或提交——任务本身就是\"输出文件结构\",所有产物都在上面的消息文本里。\n\n如果你还想看剩下两个包(`ui-conversation`、`skill-filesystem`)的结构,或者其他格式的输出(比如带文件大小的树形图),随时告诉我。"}}}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":311,"outputTokens":348,"cacheReadTokens":10624,"reasoningTokens":0}}}} {"type":"assistant/chunk","data":{"turn":2,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index be2ea25eb6..bd50420df2 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -213,7 +213,7 @@ - img - tooltip "Branch into a new conversation" - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index c09608fb95..2d9f315f43 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -29,7 +29,7 @@ - img - text: Standard mode - img -- textbox "Describe what you want to build": +- textbox "Describe what you want to build... / commands, @ files or sessions": - paragraph - button "Commands": - img diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index 396a9bfa88..5f53d73cfd 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -29,7 +29,7 @@ - img - text: Standard mode - img -- textbox "Describe what you want to build" +- textbox "Describe what you want to build... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index 39060d7135..74122b000b 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -34,7 +34,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/lifecycle-chrome/session.jsonl b/snapshots/web/lifecycle-chrome/session.jsonl index 5784efc30e..7f70ad3b5a 100644 --- a/snapshots/web/lifecycle-chrome/session.jsonl +++ b/snapshots/web/lifecycle-chrome/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530426025,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736396442,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[101,103,100,101,101,101,101,100,101,101,101,102,100,102],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[100,102,101,101,100,100,101,101,99,101,101,101,100,102],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[101,102,100,101],"texts":["L","IGH","TH","O","USE"]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[101,102,101,100],"texts":["L","IGH","TH","O","USE"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":50,"outputTokens":21,"cacheReadTokens":9950,"reasoningTokens":15}}}} diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 407235a379..0a189478b8 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -31,7 +31,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index 870fc89ffe..ae854c557c 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -23,7 +23,7 @@ - status: - text: This turn failedAPI key is invalid - code: AUTH -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 34a5ce76cd..4307a62de2 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -22,7 +22,7 @@ - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index a923ae8387..7dc6c9f613 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -25,7 +25,7 @@ - status: - text: This turn failedupstream 503 - code: SERVER -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 754c1af0ae..f1f6352fb4 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -36,7 +36,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index e2a647249e..6f8f2cd629 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -22,7 +22,7 @@ - text: Context injection @deepseek-ai/dsh-system-prompt - paragraph: partial - status: Deep diving... -- textbox "Message the agent": +- textbox "Message or run a task... / commands, @ files or sessions": - paragraph: Queue this follow-up while the current turn is running. - button "Commands": - img diff --git a/snapshots/web/live-interactions/session.jsonl b/snapshots/web/live-interactions/session.jsonl index 8d59dd19e9..606b85feab 100644 --- a/snapshots/web/live-interactions/session.jsonl +++ b/snapshots/web/live-interactions/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530424455,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736358882,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0],"texts":["The"," user"," is"," asking"," for"," a"," one","-s","entence"," description"," of"," event"," sourcing","."," This"," is"," a"," straightforward"," knowledge"," question"," that"," doesn","'t"," require"," any"," skill"," loading"," or"," tool"," calls","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],"texts":["Event"," sourcing"," is"," a"," pattern"," where"," all"," changes"," to"," an"," application","'s"," state"," are"," stored"," as"," an"," immutable",","," append","-only"," sequence"," of"," events",","," rather"," than"," pers","isting"," only"," the"," current"," state",","," enabling"," full"," audit","ability",","," temporal"," queries",","," and"," event","-driven"," architectures","."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["Event"," sourcing"," is"," a"," pattern"," where"," all"," changes"," to"," an"," application","'s"," state"," are"," stored"," as"," an"," immutable",","," append","-only"," sequence"," of"," events",","," rather"," than"," pers","isting"," only"," the"," current"," state",","," enabling"," full"," audit","ability",","," temporal"," queries",","," and"," event","-driven"," architectures","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking for a one-sentence description of event sourcing. This is a straightforward knowledge question that doesn't require any skill loading or tool calls."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Event sourcing is a pattern where all changes to an application's state are stored as an immutable, append-only sequence of events, rather than persisting only the current state, enabling full auditability, temporal queries, and event-driven architectures."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":110,"outputTokens":79,"cacheReadTokens":7680,"reasoningTokens":31}}}} diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index f3f7da0464..53f8ac5c16 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -59,7 +59,7 @@ - button "Branch into a new conversation": - img - text: 7/25 {{clock}} Ran for {{duration}} -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/minimal-preset/session.jsonl b/snapshots/web/minimal-preset/session.jsonl index 84d24d9378..c701d3e334 100644 --- a/snapshots/web/minimal-preset/session.jsonl +++ b/snapshots/web/minimal-preset/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787660564247,"cwd":"{{cwd}}","agentPreset":"minimal"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736390292,"cwd":"{{cwd}}","agentPreset":"minimal"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index 2de9eef677..73d9d436da 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -31,7 +31,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/permission-policy-context/session.jsonl b/snapshots/web/permission-policy-context/session.jsonl index 53c5c0325d..4a1898be46 100644 --- a/snapshots/web/permission-policy-context/session.jsonl +++ b/snapshots/web/permission-policy-context/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520612065,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736498971,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -16,9 +16,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["The"," user"," is"," asking"," me"," a"," direct"," question"," about"," whether"," I"," can"," create"," or"," edit"," a"," normal"," file"," under"," the"," current"," policy","."," They","'ve"," told"," me"," not"," to"," call"," a"," tool"," just"," to"," discover"," the"," policy",".\n\n","Let"," me"," answer"," based"," on"," the"," current"," runtime"," context"," provided"," in"," the"," system"," message",":\n\n","Current"," D","SH"," file"," policy",":"," read","-only","."," The"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands"," cannot"," modify"," files"," in"," the"," standing"," mode","."," However",","," the"," guidance"," says"," \"","do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," Approval"," policy"," is"," \"","ask","\""," -"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",".\n\n","So"," I"," can"," answer"," directly",":"," under"," the"," current"," read","-only"," policy",","," the"," write"," and"," edit"," tools"," would"," be"," denied",","," but"," I"," can"," attempt"," an"," escalation"," with"," justification","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," me"," a"," direct"," question"," about"," whether"," I"," can"," create"," or"," edit"," a"," normal"," file"," under"," the"," current"," policy","."," They","'ve"," told"," me"," not"," to"," call"," a"," tool"," just"," to"," discover"," the"," policy",".\n\n","Let"," me"," answer"," based"," on"," the"," current"," runtime"," context"," provided"," in"," the"," system"," message",":\n\n","Current"," D","SH"," file"," policy",":"," read","-only","."," The"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands"," cannot"," modify"," files"," in"," the"," standing"," mode","."," However",","," the"," guidance"," says"," \"","do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," Approval"," policy"," is"," \"","ask","\""," -"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",".\n\n","So"," I"," can"," answer"," directly",":"," under"," the"," current"," read","-only"," policy",","," the"," write"," and"," edit"," tools"," would"," be"," denied",","," but"," I"," can"," attempt"," an"," escalation"," with"," justification","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No"," —"," under"," the"," current"," read","-only"," file"," policy",","," `","write","`"," and"," `","edit","`"," would"," be"," denied",","," though"," I"," could"," attempt"," a"," one","-shot"," escalation"," with"," justification"," and"," wait"," for"," your"," approval","."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No"," —"," under"," the"," current"," read","-only"," file"," policy",","," `","write","`"," and"," `","edit","`"," would"," be"," denied",","," though"," I"," could"," attempt"," a"," one","-shot"," escalation"," with"," justification"," and"," wait"," for"," your"," approval","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me a direct question about whether I can create or edit a normal file under the current policy. They've told me not to call a tool just to discover the policy.\n\nLet me answer based on the current runtime context provided in the system message:\n\nCurrent DSH file policy: read-only. The write and edit tools and one-shot bash commands cannot modify files in the standing mode. However, the guidance says \"do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the Approval policy is \"ask\" - operations that require approval may ask through the configured answerers.\n\nSo I can answer directly: under the current read-only policy, the write and edit tools would be denied, but I can attempt an escalation with justification."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No — under the current read-only file policy, `write` and `edit` would be denied, though I could attempt a one-shot escalation with justification and wait for your approval."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":59,"outputTokens":201,"cacheReadTokens":14848,"reasoningTokens":163}}}} @@ -41,9 +41,9 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Does the DSH file sandbox currently restrict file operations? Answer directly in one sentence. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:2}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:5}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]}} +{"type":"reasoning-chunks","data":{"turn":2,"step":1,"index":0,"dt":[0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," is"," asking"," about"," the"," current"," D","SH"," file"," sand","box"," restrictions","."," According"," to"," the"," current"," runtime"," context"," snapshot",","," the"," file"," policy"," is"," \"","danger","-full","-access","\""," which"," means"," no"," restrictions"," on"," file"," operations","."," Also",","," approval"," prompts"," are"," disabled",","," so"," I"," should"," not"," request"," sand","box"," escalation",".\n\n","Let"," me"," answer"," directly"," in"," one"," sentence"," without"," calling"," tools","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":2,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]}} +{"type":"text-chunks","data":{"turn":2,"step":1,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1],"texts":["No",","," the"," D","SH"," file"," sand","box"," currently"," grants"," full"," access"," —"," it"," does"," not"," restrict"," file"," operations","."]}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking about the current DSH file sandbox restrictions. According to the current runtime context snapshot, the file policy is \"danger-full-access\" which means no restrictions on file operations. Also, approval prompts are disabled, so I should not request sandbox escalation.\n\nLet me answer directly in one sentence without calling tools."}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"No, the DSH file sandbox currently grants full access — it does not restrict file operations."}}}} {"type":"assistant/chunk","data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":202,"outputTokens":87,"cacheReadTokens":14848,"reasoningTokens":66}}}} @@ -66,7 +66,7 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKSPACE_POLICY_SEEN. Do not call tools."}],"source":{"kind":"user","rpcId":"{{rpc:3}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:10}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":3,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]}} +{"type":"reasoning-chunks","data":{"turn":3,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WORK","SP","ACE","_P","OL","ICY","_SE","EN","\"."]}} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"text-chunks","data":{"turn":3,"step":1,"index":1,"dt":[0,0,0,0,0,0,0],"texts":["WORK","SP","ACE","_P","OL","ICY","_SE","EN"]}} {"type":"assistant/chunk","data":{"turn":3,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WORKSPACE_POLICY_SEEN\"."}}}} @@ -87,9 +87,9 @@ {"type":"user/message","data":{"content":[{"type":"text","text":"Create the relative path policy-neutral.txt in the current workspace containing exactly POLICY_NEUTRAL_OK, verify its contents, then report completion."}],"source":{"kind":"user","rpcId":"{{rpc:4}}","clientTimeZone":"Asia/Shanghai"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:13}}"},"surfaceOp":"append"} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":1,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":1,"index":0,"dt":[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"texts":["The"," current"," policy"," is"," read","-only",","," so"," write"," tools"," will"," be"," denied","."," I"," should"," attempt"," it"," normally"," and"," follow"," the"," denial","/"," escalation"," guidance","."," Let"," me"," try"," to"," write"," the"," file","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":4,"step":1,"index":1,"dt":[0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":4,"step":1,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"","}"]}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The current policy is read-only, so write tools will be denied. I should attempt it normally and follow the denial/ escalation guidance. Let me try to write the file."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1SovMaD1sZjZwhmQuUCb7415","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\"}"}}}} {"type":"assistant/chunk","data":{"turn":4,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":265,"outputTokens":103,"cacheReadTokens":15104,"reasoningTokens":35}}}} @@ -100,9 +100,9 @@ {"type":"step/end","data":{"turn":4,"step":1}} {"type":"step/start","data":{"turn":4,"step":2}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":2,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," write"," was"," denied"," as"," expected"," under"," read","-only"," mode","."," The"," escalation"," guidance"," says"," I"," can"," ret","ry"," with"," sand","box","_per","missions"," and"," justification",","," but"," the"," approval"," policy"," says"," \"","ask","\""," —"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",";"," without"," an"," available"," answer","er",","," the"," request"," fails"," closed",".\n\n","However",","," the"," instructions"," say",":"," \"","For"," the"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands",","," do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," escalation"," guidance"," says",":"," \"","ret","ry"," this"," exact"," operation"," once"," with"," sand","box","_per","missions"," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," +"," justification",";"," the"," approval"," prompt"," asks"," the"," user","\"\n\n","So"," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," and"," provide"," a"," justification","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":2,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],"texts":["The"," write"," was"," denied"," as"," expected"," under"," read","-only"," mode","."," The"," escalation"," guidance"," says"," I"," can"," ret","ry"," with"," sand","box","_per","missions"," and"," justification",","," but"," the"," approval"," policy"," says"," \"","ask","\""," —"," operations"," that"," require"," approval"," may"," ask"," through"," the"," configured"," answer","ers",";"," without"," an"," available"," answer","er",","," the"," request"," fails"," closed",".\n\n","However",","," the"," instructions"," say",":"," \"","For"," the"," write"," and"," edit"," tools"," and"," one","-shot"," bash"," commands",","," do"," not"," refuse"," a"," required"," modification"," from"," this"," standing"," mode"," alone",":"," attempt"," it"," normally"," and"," follow"," the"," tool","'s"," denial"," and"," escalation"," guidance",".\"\n\n","And"," the"," escalation"," guidance"," says",":"," \"","ret","ry"," this"," exact"," operation"," once"," with"," sand","box","_per","missions"," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," +"," justification",";"," the"," approval"," prompt"," asks"," the"," user","\"\n\n","So"," I"," need"," to"," ret","ry"," with"," sand","box","_per","missions"," set"," to"," \"","works","pace","-w","rite","\""," (","the"," narrow","est"," wider"," mode"," that"," suff","ices",")"," and"," provide"," a"," justification","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":4,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],"id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","The"," user"," explicitly"," asked"," me"," to"," create"," this"," file",";"," workspace","-w","rite"," is"," the"," narrow","est"," mode"," sufficient"," to"," write"," to"," the"," session"," workspace",".","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":4,"step":2,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"",", ","\"","content","\"",": ","\"","POL","ICY","_","NE","UT","RAL","_OK","\"",", ","\"","sand","box","_per","missions","\"",": ","\"","works","pace","-w","rite","\"",", ","\"","just","ification","\"",": ","\"","The"," user"," explicitly"," asked"," me"," to"," create"," this"," file",";"," workspace","-w","rite"," is"," the"," narrow","est"," mode"," sufficient"," to"," write"," to"," the"," session"," workspace",".","\"","}"]}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The write was denied as expected under read-only mode. The escalation guidance says I can retry with sandbox_permissions and justification, but the approval policy says \"ask\" — operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.\n\nHowever, the instructions say: \"For the write and edit tools and one-shot bash commands, do not refuse a required modification from this standing mode alone: attempt it normally and follow the tool's denial and escalation guidance.\"\n\nAnd the escalation guidance says: \"retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user\"\n\nSo I need to retry with sandbox_permissions set to \"workspace-write\" (the narrowest wider mode that suffices) and provide a justification."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_NRXSzM55hPMs3seJDD8k7920","name":"write","arguments":"{\"file_path\": \"policy-neutral.txt\", \"content\": \"POLICY_NEUTRAL_OK\", \"sandbox_permissions\": \"workspace-write\", \"justification\": \"The user explicitly asked me to create this file; workspace-write is the narrowest mode sufficient to write to the session workspace.\"}"}}}} {"type":"assistant/chunk","data":{"turn":4,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":306,"cacheReadTokens":15360,"reasoningTokens":175}}}} @@ -115,9 +115,9 @@ {"type":"step/end","data":{"turn":4,"step":2}} {"type":"step/start","data":{"turn":4,"step":3}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":3,"index":0,"dt":[0,0,0,0,0,0,0,0,0,1,0,0,0,0],"texts":["The"," file"," was"," created","."," Now"," I"," need"," to"," verify"," its"," contents"," as"," instructed","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":4,"step":3,"index":1,"dt":[0,0,0,1,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":4,"step":3,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","args":["","{","\"","file","_path","\"",": ","\"","policy","-neutral",".txt","\"","}"]}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created. Now I need to verify its contents as instructed."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_2Iq0ZY5UVHgctdG7nana2758","name":"read","arguments":"{\"file_path\": \"policy-neutral.txt\"}"}}}} {"type":"assistant/chunk","data":{"turn":4,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":61,"cacheReadTokens":15744,"reasoningTokens":15}}}} @@ -128,9 +128,9 @@ {"type":"step/end","data":{"turn":4,"step":3}} {"type":"step/start","data":{"turn":4,"step":4}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":4,"step":4,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," and"," its"," contents"," are"," verified",":"," \"","POL","ICY","_","NE","UT","RAL","_OK","\"."," Task"," complete","."]}} +{"type":"reasoning-chunks","data":{"turn":4,"step":4,"index":0,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0],"texts":["The"," file"," was"," created"," and"," its"," contents"," are"," verified",":"," \"","POL","ICY","_","NE","UT","RAL","_OK","\"."," Task"," complete","."]}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":4,"step":4,"index":1,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Done"," —"," **","policy","-neutral",".txt","**"," was"," created"," (","via"," workspace","-w","rite"," escalation",")"," and"," verified"," to"," contain"," exactly"," `","POL","ICY","_","NE","UT","RAL","_OK","`."]}} +{"type":"text-chunks","data":{"turn":4,"step":4,"index":1,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["Done"," —"," **","policy","-neutral",".txt","**"," was"," created"," (","via"," workspace","-w","rite"," escalation",")"," and"," verified"," to"," contain"," exactly"," `","POL","ICY","_","NE","UT","RAL","_OK","`."]}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The file was created and its contents are verified: \"POLICY_NEUTRAL_OK\". Task complete."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Done — **policy-neutral.txt** was created (via workspace-write escalation) and verified to contain exactly `POLICY_NEUTRAL_OK`."}}}} {"type":"assistant/chunk","data":{"turn":4,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":211,"outputTokens":53,"cacheReadTokens":15872,"reasoningTokens":22}}}} diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 2d694d6162..c2791f6a6f 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -52,7 +52,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/plan-review/session.jsonl b/snapshots/web/plan-review/session.jsonl index 1a8e51ab3d..a9a38255c4 100644 --- a/snapshots/web/plan-review/session.jsonl +++ b/snapshots/web/plan-review/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520597662,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736375225,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -15,11 +15,11 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,15,17,17,17,17,16,16,17,16,17,18,18,16,16,17,18,15,17,18,18,16,15,18,17,17,16,17,17,16,16,18,17,16,17,15,18,18,18,17,17,17,18,16,16,16,18,17,18,15,17],"texts":["The"," user"," wants"," me"," to"," plan"," a"," small"," change"," to"," add"," a"," `","--","gre","eting","`"," flag"," to"," a"," CLI","."," They"," explicitly"," told"," me"," not"," to"," read"," or"," write"," any"," files",","," and"," to"," call"," exit","_","plan","_mode"," with"," a"," short"," plan","."," Let"," me"," do"," that"," directly","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,17,15,17,17,16,17,17,15,17,16,16,16,16,17,17,16,17,16,16,17,17,17,16,17,16,16,17,16,17,17,17,16,17,16,17,16,17,17,16,17,16,16,17,17,15,17,17,17,16,17],"texts":["The"," user"," wants"," me"," to"," plan"," a"," small"," change"," to"," add"," a"," `","--","gre","eting","`"," flag"," to"," a"," CLI","."," They"," explicitly"," told"," me"," not"," to"," read"," or"," write"," any"," files",","," and"," to"," call"," exit","_","plan","_mode"," with"," a"," short"," plan","."," Let"," me"," do"," that"," directly","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[18,16,15,17,17,18,16,17,17,16,18,18,16,18,15,17,18,15,17,16,16,16,17,17,16,18,17,17,16,17],"texts":["Since"," the"," user"," has"," explicitly"," asked"," me"," not"," to"," read"," or"," write"," any"," files"," and"," to"," go"," straight"," to"," planning",","," I","'ll"," proceed"," with"," `","exit","_","plan","_mode","`."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[16,16,17,17,16,17,16,17,16,16,16,16,17,17,16,16,17,17,16,17,16,17,17,17,16,17,17,16,17,17],"texts":["Since"," the"," user"," has"," explicitly"," asked"," me"," not"," to"," read"," or"," write"," any"," files"," and"," to"," go"," straight"," to"," planning",","," I","'ll"," proceed"," with"," `","exit","_","plan","_mode","`."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[17,17,17,17,16,18,18,17,17,17,18,16,15,16,16,17,18,18,16,18,18,18,16,16,16,17,18,18,16,18,16,18,16,16,18,14,18,16,17,16,17,17,18,16,16,16,17,15,17,17,18,16,17,18,18,16,17,17,18,18,15,16,17,17,17,18,18,17,16,17,17,17,18,16,17,18,18,17,16,16,18,16,17,17,17,16,16,17,17,18,17,17,17,18,18,17,15,16,16,18,18,18,16,17,17,15,17,17,17,17,16,15,15,17,18,17,17,16,17,17,18,17,17,16,17,16,17,17,16,15,17,17,17,17,17,17,15,17,16,17,18,18,16,18,17,17,17,17,17,18,15,15,17,18,16,14,18,15,17,18,18,17,16,17,17,16,16,17,17,17,17,17,18,17,17,17,17,18,18,17,17,18,16,17,16],"id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","args":["","{","\"","plan","\"",": ","\"","#"," Add"," `","--","gre","eting","`"," flag"," to"," CLI","\\n\\n","-"," **","Loc","ate"," the"," CLI"," entry"," point","**"," (","e",".g",".,"," `","cli",".py","`,"," `","main",".go","`,"," `","index",".js","`"," etc",".)"," and"," find"," the"," argument"," parser"," definition"," (","arg","parse",","," click",","," cob","ra",","," y","args",","," or"," similar",").\\n","-"," **","Register"," a"," new"," optional"," string"," argument","**"," named"," `","--","gre","eting","`"," with"," a"," short"," alias"," (`","-","g","`"," if"," available",")"," and"," a"," sensible"," default"," value"," (","e",".g",".,"," `","\\\"","Hello","\\\"","`",").\\n","-"," **","Thread"," the"," parsed"," value","**"," through"," the"," main"," handler"," function"," so"," it"," is"," passed"," where"," the"," greeting"," string"," is"," used"," (","e",".g",".,"," the"," welcome","/","response"," message",").\\n","-"," **","Update"," the"," help"," text","**"," so"," `","--","help","`"," or"," `","-","h","`"," shows"," the"," new"," flag"," with"," its"," description",".\\n","-"," **","No"," tests"," or"," config"," changes","**"," unless"," they"," already"," exist"," and"," directly"," validate"," the"," flag","'s"," presence",".","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[17,16,17,15,17,17,16,16,17,17,17,17,16,16,16,16,16,17,16,17,17,16,17,16,17,16,17,16,16,17,16,17,16,16,17,16,16,17,16,16,17,16,16,16,16,17,16,16,17,16,15,19,17,16,16,16,17,16,17,16,17,16,17,16,19,17,16,16,16,15,16,17,16,17,16,16,17,16,16,16,17,16,16,17,16,17,16,16,17,16,16,17,17,17,16,17,17,16,17,17,16,17,16,17,16,17,17,17,18,16,17,16,16,16,17,17,18,17,16,17,17,17,16,16,17,16,17,16,17,16,17,17,16,17,17,16,17,17,15,16,17,17,17,16,17,17,16,16,16,16,17,17,16,17,17,16,17,16,17,16,16,16,16,17,17,17,16,17,17,16,17,16,16,16,17,18,16,16,17,16,16,16,17,17,15],"id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","args":["","{","\"","plan","\"",": ","\"","#"," Add"," `","--","gre","eting","`"," flag"," to"," CLI","\\n\\n","-"," **","Loc","ate"," the"," CLI"," entry"," point","**"," (","e",".g",".,"," `","cli",".py","`,"," `","main",".go","`,"," `","index",".js","`"," etc",".)"," and"," find"," the"," argument"," parser"," definition"," (","arg","parse",","," click",","," cob","ra",","," y","args",","," or"," similar",").\\n","-"," **","Register"," a"," new"," optional"," string"," argument","**"," named"," `","--","gre","eting","`"," with"," a"," short"," alias"," (`","-","g","`"," if"," available",")"," and"," a"," sensible"," default"," value"," (","e",".g",".,"," `","\\\"","Hello","\\\"","`",").\\n","-"," **","Thread"," the"," parsed"," value","**"," through"," the"," main"," handler"," function"," so"," it"," is"," passed"," where"," the"," greeting"," string"," is"," used"," (","e",".g",".,"," the"," welcome","/","response"," message",").\\n","-"," **","Update"," the"," help"," text","**"," so"," `","--","help","`"," or"," `","-","h","`"," shows"," the"," new"," flag"," with"," its"," description",".\\n","-"," **","No"," tests"," or"," config"," changes","**"," unless"," they"," already"," exist"," and"," directly"," validate"," the"," flag","'s"," presence",".","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to plan a small change to add a `--greeting` flag to a CLI. They explicitly told me not to read or write any files, and to call exit_plan_mode with a short plan. Let me do that directly."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Since the user has explicitly asked me not to read or write any files and to go straight to planning, I'll proceed with `exit_plan_mode`."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_TPh5fH8x5kRezK115kgx8155","name":"exit_plan_mode","arguments":"{\"plan\": \"# Add `--greeting` flag to CLI\\n\\n- **Locate the CLI entry point** (e.g., `cli.py`, `main.go`, `index.js` etc.) and find the argument parser definition (argparse, click, cobra, yargs, or similar).\\n- **Register a new optional string argument** named `--greeting` with a short alias (`-g` if available) and a sensible default value (e.g., `\\\"Hello\\\"`).\\n- **Thread the parsed value** through the main handler function so it is passed where the greeting string is used (e.g., the welcome/response message).\\n- **Update the help text** so `--help` or `-h` shows the new flag with its description.\\n- **No tests or config changes** unless they already exist and directly validate the flag's presence.\"}"}}}} @@ -33,7 +33,7 @@ {"type":"step/start","data":{"turn":1,"step":2}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,16,17,17,16,16,16,17,16,17,17,18,16,16,18,18,17,15,15,17,18,18,16,16,17,17,18,18,16,17,18,15,17,18,15,17,17,18],"texts":["The"," plan"," was"," approved","."," The"," user","'s"," last"," instruction"," says",":"," \"","Once"," the"," plan"," is"," approved",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop",".\""," So"," I"," should"," just"," reply"," with"," D","ONE"," and"," stop","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,16,15,17,17,16,16,15,17,17,16,17,16,17,17,15,17,17,16,17,17,16,17,16,17,17,17,16,16,17,16,17,16,16,16,17,15,17,56],"texts":["The"," plan"," was"," approved","."," The"," user","'s"," last"," instruction"," says",":"," \"","Once"," the"," plan"," is"," approved",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop",".\""," So"," I"," should"," just"," reply"," with"," D","ONE"," and"," stop","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index c516407e3d..4aab68711a 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -42,7 +42,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/question-composer/session.jsonl b/snapshots/web/question-composer/session.jsonl index 12eaad22d2..214bb0d797 100644 --- a/snapshots/web/question-composer/session.jsonl +++ b/snapshots/web/question-composer/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787520604916,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736424589,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,9 +12,9 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[17,16,18,18,18,15,17,16,17,18,16,18,18,16,17,17,16,17,17,16,18],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[16,17,15,17,16,16,16,15,16,16,16,16,16,17,17,17,17,16,17,16,17],"texts":["The"," user"," wants"," me"," to"," use"," the"," ask","_user","_","question"," tool"," with"," specific"," parameters","."," Let"," me"," do"," exactly"," that","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[17,17,17,17,18,15,16,15,16,16,17,16,17,17,17,16,17,16,18,17,16,17,17,17,18,17,15,17,17,18,16,18,16,17,16,18,16,17,15,17,17,17,18,16,17,17,17,18,18,16,16,18,18,17,17,17,15,18,16,17,18,16,16,18,17,17,18,15,17,15,16,17,16,18,17,16,18,17,17,16,16,17,17,16,17,17,17,16,16,16,18,17,17,18,16,16,16],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":1,"dt":[16,17,17,17,16,17,16,17,17,15,17,16,16,16,17,17,17,17,16,17,17,17,17,16,17,16,16,16,17,16,16,17,17,17,17,17,16,17,16,16,16,17,16,16,17,16,16,17,17,17,17,16,17,15,16,17,16,16,16,17,17,16,17,15,16,16,15,17,16,18,16,16,16,17,17,16,17,17,17,17,16,16,17,15,16,17,16,17,16,16,17,17,17,16,16,15,16],"id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","args":["","{","\"","questions","\"",": ","[","{\"","id","\":"," \"","color","\","," \"","question","\":"," \"","Which"," color"," do"," you"," prefer","?\","," \"","header","\":"," \"","Pick"," one","\", \"multi_select\": true,"," \"","options","\":"," [","{\"","label","\":"," \"","Blue","\","," \"","description","\":"," \"","A"," cool"," recessive"," hue"," that"," reads"," as"," calm"," and"," trustworthy"," in"," long"," reading"," sessions"," and"," dense"," dash","boards",".\"","},"," {\"","label","\":"," \"","Green","\","," \"","description","\":"," \"","A"," rest","ful"," mid","-spect","rum"," hue"," with"," the"," highest"," perceived"," brightness",","," easiest"," on"," the"," eye"," over"," long"," sessions",".\"","}]","}]","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the ask_user_question tool with specific parameters. Let me do exactly that."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_Cijldc88LYmVPCXYUsRq1617","name":"ask_user_question","arguments":"{\"questions\": [{\"id\": \"color\", \"question\": \"Which color do you prefer?\", \"header\": \"Pick one\", \"multi_select\": true, \"options\": [{\"label\": \"Blue\", \"description\": \"A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.\"}, {\"label\": \"Green\", \"description\": \"A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions.\"}]}]}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":113,"outputTokens":158,"cacheReadTokens":4096,"reasoningTokens":22}}}} @@ -25,7 +25,7 @@ {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[17,17,16,18,18,18,16,17,17,16,16,16,16,17,15,16,17,18],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":2,"index":0,"dt":[16,16,16,15,15,16,18,16,16,17,17,16,16,16,16,17,16,18],"texts":["The"," user"," answered"," \"","Blue","\"."," I"," should"," now"," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index 0e13eb6e8d..ee54e52f13 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -50,7 +50,7 @@ - img - button "Steer queued message" [disabled]: - img -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 483cb20095..a5024197d8 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -52,7 +52,7 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index 11045defb8..06d9da3475 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -56,7 +56,7 @@ - img - text: "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}. Session sharing is not configured." - text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}. Session sharing is not configured." -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Read Only"': Read Only diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index dea504fe85..46e96f4b15 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -50,7 +50,7 @@ - img - img - text: Context injection AGENTS.md -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index ca0e12d5cd..8426371014 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -45,7 +45,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Full access"': Full access diff --git a/snapshots/web/steering/session.jsonl b/snapshots/web/steering/session.jsonl index c207a04dfb..c3952db9bf 100644 --- a/snapshots/web/steering/session.jsonl +++ b/snapshots/web/steering/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787528667010,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736216269,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index 561e1a5342..0b866129f4 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -37,7 +37,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index e7f9d0aee9..7f09614d07 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -63,7 +63,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Custom"': Custom diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index dc3cd57ff6..d79602b47c 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -32,7 +32,7 @@ - text: Bash Print alpha to stdout - paragraph: partial - status: Deep diving... -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/session.jsonl b/snapshots/web/turn-tail-actions/session.jsonl index a3c770f6b8..b92aff9b8a 100644 --- a/snapshots/web/turn-tail-actions/session.jsonl +++ b/snapshots/web/turn-tail-actions/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787530432972,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736446077,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} @@ -12,11 +12,11 @@ {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," begin"," with"," \"","Reading"," the"," workspace"," now",".\""," and"," call"," bash"," with"," \"","echo"," alpha","\""," in"," the"," same"," message","."," Then"," after"," the"," tool"," result",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} +{"type":"reasoning-chunks","data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," begin"," with"," \"","Reading"," the"," workspace"," now",".\""," and"," call"," bash"," with"," \"","echo"," alpha","\""," in"," the"," same"," message","."," Then"," after"," the"," tool"," result",","," reply"," with"," the"," single"," word"," D","ONE"," and"," stop","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,0],"texts":["Reading"," the"," workspace"," now","."]}} +{"type":"text-chunks","data":{"turn":1,"step":1,"index":1,"dt":[1,0,0,0],"texts":["Reading"," the"," workspace"," now","."]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," alpha","\"",", ","\"","description","\"",": ","\"","Print"," alpha"," to"," stdout","\"","}"]}} +{"type":"tool-call-chunks","data":{"turn":1,"step":1,"index":2,"dt":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0],"id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," alpha","\"",", ","\"","description","\"",": ","\"","Print"," alpha"," to"," stdout","\"","}"]}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to begin with \"Reading the workspace now.\" and call bash with \"echo alpha\" in the same message. Then after the tool result, reply with the single word DONE and stop."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"Reading the workspace now."}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_00_1yZGg4XTqe0N5r1rnDLx5082","name":"bash","arguments":"{\"command\": \"echo alpha\", \"description\": \"Print alpha to stdout\"}"}}}} diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 0a031aec85..42cfbdaacd 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -41,7 +41,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index fd3b508cb2..9886eaac0a 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -52,7 +52,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write diff --git a/snapshots/web/web-search-round/session.jsonl b/snapshots/web/web-search-round/session.jsonl index e9a002fb80..cb00d1bd79 100644 --- a/snapshots/web/web-search-round/session.jsonl +++ b/snapshots/web/web-search-round/session.jsonl @@ -1,4 +1,4 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787628993278,"cwd":"{{cwd}}","agentPreset":"standard"} +{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787736507309,"cwd":"{{cwd}}","agentPreset":"standard"} {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 746c5cff00..26ffed2add 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -34,7 +34,7 @@ - button "Branch into a new conversation": - img - text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s -- textbox "Message the agent" +- textbox "Message or run a task... / commands, @ files or sessions" - button "Commands": - img - 'button "Access mode, current: Workspace Write"': Workspace Write From 075cfc3b4d82995661450a23d82c4c4a457cf1b7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 21:52:44 +0800 Subject: [PATCH 022/130] test: give the dual-call built-bin cases a 150s outer budget The plugin add and dump-default-config cases serialize two runBuiltBin calls, each with a 60s execa cap; the 90s outer budget could be exhausted before the second call. Raise them to SPAWN_TIMEOUT_MS * 2 + 30s, matching the multi-call treatment. --- apps/cli/tests/built-bin.e2e.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 4515d70b2e..c34a9a0896 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -903,7 +903,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } finally { rmSync(home, { recursive: true, force: true }) } - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) describe('config dump', () => { let home: string @@ -965,7 +965,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('# == @deepseek-ai/dsh-sdk-minimal') expect(stdout).not.toContain('@deepseek-ai/dsh-base') expect(stdout).not.toContain('@deepseek-ai/dsh-web-app') - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { // Auto-init the web profile first, then write its user layer. @@ -1004,6 +1004,6 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // Both layers patched the row; the comment lists them in application order. expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') - }, SPAWN_TIMEOUT_MS + 30_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) }) }) From 36dd657c7ef6a8cfba1ce993955a5147cf7a6944 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Wed, 26 Aug 2026 22:10:58 +0800 Subject: [PATCH 023/130] test(ui-input-trigger): cover the onHover slot wiring; restore master's notices The merge resolution had reverted THIRD_PARTY_NOTICES.md to the SDK 0.3.220 rows; the lockfile pins 0.3.241, so CI regenerated a mismatch. The apply spec now drives the injected onHover face, closing the per-file coverage gap on src/client/index.ts. --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- .../tests/apply.client.spec.ts | 3 +++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d47cdd7d2c..08c7f13f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 182252ee38..0b66b9366a 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -84,6 +84,9 @@ describe('apply', () => { // The pick face routes into the controller pipeline (closed menu → no-op). injected.onPick('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) + // The hover face routes into the controller too (closed menu → no-op). + injected.onHover('command', 0) + expect(controller.menu.getSnapshot().open).toBe(false) // The dismiss face routes into the controller too (closed menu → no-op). injected.onDismiss() expect(controller.menu.getSnapshot().open).toBe(false) From d77b64e7cfe364e7cc61c7a4ab969a3944119f2e Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 26 Aug 2026 22:42:55 +0800 Subject: [PATCH 024/130] test: derive the plugin add/remove budget and note the exe build The anchors-a-relative-add-spec case serializes two subprocesses (plugin add + remove) under a hardcoded 90s budget, which the 2x60s worst case exhausts; derive it from SPAWN_TIMEOUT_MS * 2 + 30s like the other dual-call cases. The pnpm setup isolation note now also records the python SDK exe build's suffixed destination and its regression-test coverage. --- .../bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml | 4 ++-- .../bug-fix/2026-07-29-pnpm-setup-runner-isolation.md | 4 ++-- .../bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md | 4 ++-- apps/cli/tests/built-bin.e2e.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml index 68f963f3ea..d87d9b8af7 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.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-29-pnpm-setup-runner-isolation.md -2026-07-29-pnpm-setup-runner-isolation.md: 14a609c56eb45f706c7451d2659ab397c2886411 -2026-07-29-pnpm-setup-runner-isolation.zh.md: cdb13f9cfb7e2abf7d1563c39526ea2d9e1ca562 +2026-07-29-pnpm-setup-runner-isolation.md: 40a34460171777a1cf2a8d2fb940d74012113c1c +2026-07-29-pnpm-setup-runner-isolation.zh.md: 4c307bc00e980975d35a1a0c3e776d5fe371cec9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md index 14a609c56e..40a3446017 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.md @@ -10,9 +10,9 @@ English | [中文](2026-07-29-pnpm-setup-runner-isolation.zh.md) ## Decision -Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). +Every non-Windows `pnpm/action-setup` step in [the primary CI workflow](../../../../.github/workflows/ci.yml) and [the master workflow](../../../../.github/workflows/ci-master.yml) sets `dest: ${{ runner.temp }}/setup-pnpm`. Each runner service owns its temporary directory, so one setup cannot replace another runner's install directory. The Windows native jobs and the [python SDK exe build](../../../../.github/workflows/build-exe-for-python-sdk.yml) use a separate pnpm executable under `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` (not `standalone: true`; the destination keeps that executable apart): the run/attempt/job suffix gives every job a fresh directory even when sequential jobs land on the same self-hosted runner and a previous job leaves a locked @reflink native module. Persistent store reuse remains separate through `PNPM_CONFIG_STORE_DIR`, as established by the [pnpm provisioning decision](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.md). -[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml` and `ci-master.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. +[The workflow regression test](../../../../scripts/ci-workflow.spec.ts) discovers every `pnpm/action-setup` step in `ci.yml`, `ci-master.yml`, and `build-exe-for-python-sdk.yml` and rejects one without the runner-private destination. This keeps newly added jobs inside the same isolation boundary. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md index cdb13f9cfb..4c307bc00e 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-pnpm-setup-runner-isolation.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 +[主 CI 工作流](../../../../.github/workflows/ci.yml)与 [CI master 工作流](../../../../.github/workflows/ci-master.yml)中的每个**非 Windows** `pnpm/action-setup` 步骤都设置 `dest: ${{ runner.temp }}/setup-pnpm`。每个 runner 服务独占自己的临时目录,因此一个设置过程无法替换另一个 runner 的安装目录。Windows 原生作业与 [python SDK exe 构建](../../../../.github/workflows/build-exe-for-python-sdk.yml)在 `setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}` 下使用独立的 pnpm 可执行文件(非 `standalone: true`,目录本身起分离作用):run/attempt/job 后缀让每次作业都使用全新目录,即使顺序作业落到同一自托管 runner、且前一作业留下被锁定的 @reflink 原生模块。持久 store 的复用仍由 `PNPM_CONFIG_STORE_DIR` 独立处理,遵循 [pnpm 配置决策](../process/2026-07-26-pnpm-action-setup-for-symmetric-ci-caching.zh.md)。 -[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml` 与 `ci-master.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 +[工作流回归测试](../../../../scripts/ci-workflow.spec.ts)会找出 `ci.yml`、`ci-master.yml` 与 `build-exe-for-python-sdk.yml` 中的每个 `pnpm/action-setup` 步骤,并拒绝缺少 runner 专属目标目录的步骤。这可确保后续新增的作业也处于同一隔离边界内。 ## 曾考虑的替代方案 diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index c34a9a0896..63f58916f2 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -866,7 +866,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', rmSync(home, { recursive: true, force: true }) rmSync(checkout, { recursive: true, force: true }) } - }, 90_000) + }, SPAWN_TIMEOUT_MS * 2 + 30_000) it('activates a dependency that gained dsh.bundle in a later update', async () => { // Reconcile runs against the INSTALLED state on every successful pnpm From 94db8e881bf7d68baf97acebb69e82b5668bd3de Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 11:28:59 +0800 Subject: [PATCH 025/130] fix(web): render readable ask-user transcripts --- ...29-ask-question-web-presentation.i18n.yaml | 4 +- ...026-07-29-ask-question-web-presentation.md | 14 +- ...-07-29-ask-question-web-presentation.zh.md | 14 +- apps/web/tests/question-composer.e2e.ts | 118 ++++++++++- .../ui-conversation/src/client/locales.ts | 6 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 4 +- packages/client/ui-tool/README.zh.md | 4 +- .../src/client/tool/components/ToolRow.tsx | 124 ++++++------ .../toolviews/ask-question-row.module.css | 67 +++++++ .../tool/toolviews/ask-question-row.tsx | 189 ++++++++++++++++-- .../tests/ask-question-row.client.spec.tsx | 113 +++++++++-- .../question-composer/answered.expected.md | 6 +- .../question-composer/cancelled.expected.md | 41 ++++ 14 files changed, 600 insertions(+), 108 deletions(-) create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css create mode 100644 snapshots/web/question-composer/cancelled.expected.md diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 8cf0c30e2b..5d1e095629 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.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-29-ask-question-web-presentation.md -2026-07-29-ask-question-web-presentation.md: fd6c326ccadc83cb9d2edc0151dd94984f2bca8b -2026-07-29-ask-question-web-presentation.zh.md: c26fe91c91280c3f5596a30a3e3483ed1fa784b4 +2026-07-29-ask-question-web-presentation.md: c51c6458fc01d4d99ec9784566439cfca31c43ad +2026-07-29-ask-question-web-presentation.zh.md: ab027b33ba2874547b986251665d611f5b984762 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md index fd6c326cca..c51c6458fc 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -14,6 +14,10 @@ Separately, the composer visuals had drifted from the current design: an expand- A pending question owns exactly two surfaces: the composer takeover collects the answers, and a dedicated `ask_user_question` toolview row in the transcript names the interaction outcome. The row registers into the keyed `tool.call.toolview` hole exactly like `todo_write` and composes the shared `ToolRow` (chrome, running sweep, leading expansion). Its summary is the interaction verdict rather than args: `waiting` while running, `N/M answered` from the result JSON once settled (a skipped answer — empty `selected`, no `custom` — stays out of the count), `cancelled` for `ASK_CANCELLED`, and `interrupted` with the shared amber stopped semantics for `ASK_ABORTED`. Malformed or truncated results fall back to the generic summary. `PendingCard` narrowed to `PendingWait<'approval'>` and `ChatView` filtered the pending list to approval waits, leaving the placeholder card to approvals alone; the approval composer takeover ([web permission and approval](2026-07-23-web-permission-and-approval.md)) has since removed it entirely. +A successful row keeps the collapsed transcript to its one-line verdict and replaces generic JSON in the expanded body with a read-only question transcript. The presenter validates questions from call args and answers from result content, pairs them by the echoed stable `id`, preserves call order, and renders each model-authored question in a muted label followed by its selected and custom answer lines in primary text. A skipped question shows the localized `Not answered` verdict. The card caps its height and scrolls internally. Invalid JSON, duplicate ids, missing ids, count mismatches, unknown answer ids, and invalid visible fields all retain the generic input/output card instead of presenting a partial or incorrectly paired transcript. + +A cancelled or interrupted row has no answer payload to pair. Its expanded card shows a localized set-level verdict that no answers were submitted followed by the original model-authored questions; it does not label cancellation as a per-question skip or fabricate answer records. Invalid call args retain the generic diagnostic card. Cancellation uses the neutral settled state because the user chose it deliberately; interruption keeps the amber stopped state. + The composer redesign moves paging into the footer next to the actions, renders multi-select options with explicit checkboxes, keeps single-select numbered rows, and replaces the expand-to-open custom entry with an always-visible custom input row (textarea for optionless questions). The `parseQuestionTitle` multi-select suffix convention is deleted; `multi_select` is already structured metadata, so the title renders verbatim. Composer chrome copy becomes bilingual: the plugin registers zh/en dictionaries under the `question` namespace of `dsh-client-locale` and hands the entry a namespace-bound translator plus the locale snapshot as a hooks-compartment source through the slot inject face, so a locale flip re-renders a mounted composer. Validation feedback is stored as a dictionary key and re-translated on flip; carrier failure messages and all model-authored question/option text render verbatim. @@ -24,11 +28,13 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover **Keep rendering questions through `PendingCard`.** Rejected: the card was a read-only placeholder from before the takeover existed, so a pending question showed the same content twice with one copy not answerable. The toolview row plus takeover covers both the transcript record and the collection surface. -**Show the questions or answers inline in the transcript row.** Rejected: the composer takeover owns question rendering and answer collection, and the row convention (`todo_write`) is one line with details in the panel. The row therefore reports only the outcome, mirroring how the todo row reports counts while the panel owns the list. +**Show the questions or answers in the collapsed transcript row.** Rejected: the composer takeover owns answer collection, and the row convention (`todo_write`) keeps the collapsed line scannable. The row therefore reports only the outcome until expanded; its expanded body owns the read-only question transcript. + +**Keep raw input and output JSON in the expanded body.** Rejected: the payload preserves all information but makes the user's own answers or the cancelled questions difficult to scan. The structured view presents the same authored text while retaining raw JSON as the fail-closed fallback when question parsing or answer pairing is not trustworthy. **Render `ASK_CANCELLED`/`ASK_ABORTED` through the generic error shape.** Rejected: dismissal is the user's own deliberate action and an interrupt is the shared stop gesture; both are expected outcomes, not tool failures. Naming the verdict (and keeping amber stopped semantics for the abort) matches how interrupted tool calls read elsewhere. -**Translate the row verdicts now.** Deferred by explicit product decision: the row's `waiting`/`answered`/`cancelled`/`interrupted` strings stay English for this change; the composer chrome i18n landed because its Chinese-only copy was already wrong for the en locale. +**Keep the row verdicts in English.** Initially deferred, then superseded when Client UI copy became locale-owned: the current conversation dictionaries localize the row verdicts and the expanded card's skipped-answer label, while model-authored questions and answers remain verbatim. **Keep the title-suffix multi-select convention.** Rejected: `multi_select` is structured request metadata and the checkbox affordance now carries the signal, so parsing `(可多选)` out of model text was a fragile duplicate channel. @@ -36,10 +42,10 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover `ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. -The row verdict strings are the one remaining hardcoded-English surface of the question flow; localizing them is deferred follow-up. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. +The expanded transcript adds a structured-body path to the shared `ToolRow`; other tool views retain their existing generic or specialized cards. The question row reads only persisted call and result fields and does not add a Host presentation field. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. `ui-user-questions` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. ## Verification -`ui-conversation` tests pin the row's waiting/answered/skipped/cancelled/interrupted/fallback matrix, the approval-only pending filter, and the slot registration; `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. +`ui-tool` tests pin the row's waiting/answered/skipped/cancelled/interrupted matrix, readable id-based pairing, selected and custom answer lines, no-answer verdicts, and fail-closed fallback. Keyless assembled-Web snapshots expand successful and cancelled question rows and record their readable transcripts. `ui-user-questions` tests pin the redesigned composer (checkbox multi-select, always-visible custom row, footer pager, dictionary-key feedback re-translation, IME-safe Enter) and the plugin's dictionary registration plus inject face; `ui-primitives` tests pin the icon set. The assembled Web GUI was exercised against a live session covering answer, cancel, and turn-interrupt paths. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index c26fe91c91..ab027b33ba 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -14,6 +14,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, 一个待回答的问题恰好拥有两个界面:输入区接管收集回答,会话记录中一个专门的 `ask_user_question` toolview 行陈述交互结果。该行与 `todo_write` 完全一样注册进带 key 的 `tool.call.toolview` 槽位,并复用共享的 `ToolRow`(外观、运行扫光、前导展开)。其摘要是交互裁决而非参数:运行中显示 `waiting`,结算后从结果 JSON 得出 `N/M answered`(被跳过的回答 —— `selected` 为空且无 `custom` —— 不计入),`ASK_CANCELLED` 显示 `cancelled`,`ASK_ABORTED` 显示 `interrupted` 并沿用共享的琥珀色 stopped 语义。畸形或截断的结果回退到通用摘要。`PendingCard` 曾收窄为 `PendingWait<'approval'>`,`ChatView` 曾将待处理列表过滤为仅审批等待,使占位卡片只服务于审批;其后审批输入区接管([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md))已将它彻底移除。 +成功的问题行仍让折叠后的会话记录只显示单行裁决,并在展开内容中用只读问答记录取代通用 JSON。presenter 校验调用参数中的问题与结果内容中的回答,按回显的稳定 `id` 配对、保持调用顺序,并将每段模型撰写的问题显示为弱化标签,下方用主要文字显示已选项和自定义回答。被跳过的问题显示本地化的 `未回答` 裁决。卡片限制最大高度并在内部滚动。无效 JSON、重复 id、缺少 id、数量不符、未知回答 id 与无效可见字段都保留通用输入/输出卡片,不呈现不完整或错误配对的记录。 + +已取消或已中断的问题行没有可供配对的回答载荷。其展开卡片先显示本地化的整组裁决,说明未提交回答,再列出模型撰写的原始问题;它不会把取消标成逐题跳过,也不会虚构回答记录。无效调用参数保留通用诊断卡片。取消是用户主动选择,使用中性 settled 状态;中断保留琥珀色 stopped 状态。 + 输入区重设计将分页移到底部操作区旁,多选选项渲染显式复选框,单选保留编号行,并用始终可见的自定义输入行取代展开式自定义入口(无选项问题用多行文本框)。删除 `parseQuestionTitle` 的多选后缀约定;`multi_select` 已是结构化元数据,标题原样渲染。 输入区界面文案实现双语:插件在 `dsh-client-locale` 的 `question` 命名空间下注册中英词典,并通过 slot inject face 向条目提供绑定命名空间的翻译器和作为 hooks compartment 来源的 locale 快照,语言切换时已挂载的输入区会重新渲染。校验反馈以词典 key 存储、切换时重新翻译;载体失败消息与所有模型撰写的问题/选项文本原样渲染。 @@ -24,11 +28,13 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, **继续通过 `PendingCard` 渲染问题。** 否决:该卡片是接管存在之前的只读占位,导致同一内容显示两份且其中一份不可作答。toolview 行加接管同时覆盖了记录与收集两个面。 -**在会话记录行内联显示问题或回答。** 否决:输入区接管拥有问题渲染与回答收集,而行的约定(`todo_write`)是单行、详情在面板。因此行只报告结果,正如 todo 行报告计数而面板拥有列表。 +**在折叠的会话记录行显示问题或回答。** 否决:输入区接管拥有回答收集,而行的约定(`todo_write`)让折叠单行保持易扫描。因此该行在展开前只报告结果,展开内容拥有只读问答记录。 + +**在展开内容中保留原始输入与输出 JSON。** 否决:载荷保留了全部信息,却让用户自己的回答或已取消的问题难以浏览。结构化视图展示相同的作者文本;问题解析或回答配对不可信时仍以原始 JSON 作为 fail-closed 回退。 **用通用错误形态渲染 `ASK_CANCELLED`/`ASK_ABORTED`。** 否决:放弃是用户自己的主动操作,打断是共享的停止手势;两者都是预期结果而非工具失败。命名裁决(且中止保持琥珀色 stopped 语义)与其他被打断的工具调用的呈现一致。 -**现在就翻译行内裁决文案。** 依明确的产品决定推迟:本次改动中行的 `waiting`/`answered`/`cancelled`/`interrupted` 字符串保持英文;输入区界面文案的国际化落地是因为其仅中文的文案在 en 语言下本就是错的。 +**让行内裁决保持英文。** 最初延期,随后在 Client UI 文案改由 locale 管理时被取代:当前 conversation 词典本地化行内裁决与展开卡片的未回答标签,而模型撰写的问题和回答保持原样。 **保留标题后缀的多选约定。** 否决:`multi_select` 是结构化请求元数据且复选框标识已承载该信号,从模型文本解析 `(可多选)` 是脆弱的重复通道。 @@ -36,10 +42,10 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, `ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的 slot 注册。专用的 `todo-row.module.css` 已删除。 -行内裁决字符串是问题流程仅剩的硬编码英文面;将其本地化是推迟的后续工作。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 +展开问答记录为共享 `ToolRow` 增加一条结构化内容路径;其他工具视图保留原有的通用或专用卡片。问题行只读取已持久化的调用与结果字段,不增加 Host 呈现字段。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 `ui-user-questions` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其约定(`QuestionComposerInjected`)与消费方一起放在 `contract/slots.ts`。 ## 验证 -`ui-conversation` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted/回退矩阵、仅审批的待处理过滤和 slot 注册;`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 +`ui-tool` 测试钉住行的 waiting/answered/skipped/cancelled/interrupted 矩阵、可读的 id 配对、已选项与自定义回答行、无回答裁决和 fail-closed 回退。无密钥的组装 Web 快照分别展开成功与取消的问题行并记录其可读内容。`ui-user-questions` 测试钉住重设计的输入区(复选框多选、始终可见的自定义行、底部分页、词典 key 反馈重翻译、IME 安全的 Enter)以及插件的词典注册与 inject face;`ui-primitives` 测试钉住图标集。组装后的 Web GUI 在真实会话中演练了回答、取消与轮次打断路径。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 4dff77b911..36aadf2f20 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -17,7 +17,7 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' @@ -29,7 +29,9 @@ const COMPOSED_EXPECTED = join(SNAPSHOT_DIR, 'composed.expected.md') // Final golden: the answered transcript — the question resolved into its tool // round trip and the final reply, the state the composer goldens cannot see. const ANSWERED_EXPECTED = join(SNAPSHOT_DIR, 'answered.expected.md') +const CANCELLED_EXPECTED = join(SNAPSHOT_DIR, 'cancelled.expected.md') const MODE = webSnapshotMode() +const CANCELLED_SEED_ID = 'ask-question-cancelled-row-web-e2e' // The composer's own growth cap, in text lines (QuestionComposer.module.css // .fieldMirror). Asserted as TEXT lines, not as a box height: the two variants @@ -61,6 +63,51 @@ async function capMetrics(field: Locator): Promise<{ textLines: number; scrolls: // collapsed row painting its copy outside its own box. const PROMPT = 'Use the ask_user_question tool to ask me exactly one multi-select question with id "color", question "Which color do you prefer?", header "Pick one", and two options: label "Blue" with description "A cool recessive hue that reads as calm and trustworthy in long reading sessions and dense dashboards.", and label "Green" with description "A restful mid-spectrum hue with the highest perceived brightness, easiest on the eye over long sessions." Set multi_select to true. After I answer, reply with the single word DONE and stop.' +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Replace the successful Tool settlement and omit the answer-dependent model step. */ +function cancelledFixture(fixture: string): string { + let replaced = false + const lines: string[] = [] + for (const line of fixture.trimEnd().split('\n')) { + const event: unknown = JSON.parse(line) + if (!isRecord(event)) throw new Error('question fixture event is invalid') + if (event.type === 'session') { + event.createdAt = Date.now() + lines.push(JSON.stringify(event)) + continue + } + if (replaced) { + const data = event.data + if ((event.type === 'step/end' && isRecord(data) && data.step === 1) + || event.type === 'turn/end') lines.push(line) + continue + } + if (event.type !== 'tool/result') { + lines.push(line) + continue + } + const data = event.data + if (!isRecord(data)) throw new Error('question fixture tool/result data is invalid') + const message = data.message + if (!isRecord(message) || !Array.isArray(message.content) || !isRecord(message.content[0])) { + throw new Error('question fixture tool/result message is invalid') + } + message.content[0].content = [{ + type: 'text', + text: 'Error: the user cancelled ask_user_question', + }] + message.content[0].isError = true + data.error = { name: 'UserQuestionError', code: 'ASK_CANCELLED' } + replaced = true + lines.push(JSON.stringify(event)) + } + if (!replaced) throw new Error('question fixture has no tool/result event') + return `${lines.join('\n')}\n` +} + describe('web e2e: resident question composer round trip', () => { let scaffold: WebScaffold let browser: Browser @@ -237,7 +284,13 @@ describe('web e2e: resident question composer round trip', () => { expect(await selectedRow.locator('[data-state="warning"]').count()).toBe(0) await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) // Golden of the answered transcript: the ask_user_question round trip - // rendered as history (question tool row + DONE), composer takeover gone. + // rendered as history (expanded readable answers + DONE), composer takeover gone. + const answeredRow = page.getByRole('button', { name: 'Ask question 1/1 answered', exact: true }) + await answeredRow.click() + await page.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await page.getByText('Blue', { exact: true }).count()).toBeGreaterThanOrEqual(1) + expect(await page.getByText('Include accessibility notes', { exact: true }).count()).toBe(1) + expect(await page.getByText(/"answers"/).count()).toBe(0) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ANSWERED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) @@ -285,13 +338,72 @@ describe('web e2e: resident question composer round trip', () => { await expect.poll(() => page.locator('[data-question-key]').count(), { timeout: 10_000 }).toBe(0) }, 60_000) - it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { +}) + +describe.skipIf(MODE === 'record')('web e2e: cancelled question transcript', () => { + let cancelledScaffold: WebScaffold + let cancelledBrowser: Browser + let cancelledPage: Page + let cancelledTripwire: ReturnType + + beforeAll(async () => { + cancelledScaffold = await launchWebScaffold({}) + await seedSession( + cancelledScaffold, + cancelledFixture(await readFile(FIXTURE, 'utf8')), + CANCELLED_SEED_ID, + ) + cancelledBrowser = await chromium.launch() + cancelledPage = await newEnglishPage(cancelledBrowser) + cancelledTripwire = watchConsole(cancelledPage) + await cancelledPage.goto(cancelledScaffold.authenticatedUrl, { waitUntil: 'load' }) + await cancelledPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = cancelledPage.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = cancelledPage.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + }, 120_000) + + afterAll(async () => { + await cancelledBrowser?.close() + await cancelledScaffold?.close() + }) + + it('expands to the cancellation verdict and original questions', async () => { + onTestFailed(() => saveFailureShot(cancelledPage, 'web-e2e-question-cancelled-row')) + const row = cancelledPage.getByRole('button', { name: 'Ask question cancelled', exact: true }) + await row.waitFor({ timeout: 15_000 }) + await row.click() + + await cancelledPage + .getByText('This question set was cancelled before answers were submitted.', { exact: true }) + .waitFor({ timeout: 10_000 }) + await cancelledPage.getByText('Which color do you prefer?', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await cancelledPage.getByText(/"questions"/).count()).toBe(0) + expect(await cancelledPage + .getByText('Error: the user cancelled ask_user_question', { exact: true }).count()).toBe(0) + + const snapshot = (await captureStableAria( + cancelledPage, + '[class*="centerCol"]', + cancelledScaffold.workspaceCwd, + )).split(CANCELLED_SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(CANCELLED_EXPECTED, snapshot, MODE) + expect(cancelledTripwire.pageErrors).toEqual([]) + expect(cancelledTripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.jsonl', 'ui.expected.md', 'sidebar.expected.md', 'composed.expected.md', 'answered.expected.md', + 'cancelled.expected.md', ]) }) }) diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 480f73764b..853a1ace99 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -76,8 +76,11 @@ export const zh = { 'ask.rowTitle': '提问', 'ask.waiting': '等待回答', 'ask.cancelled': '已取消', + 'ask.cancelledDetail': '本轮已取消,未提交回答', 'ask.interrupted': '已中断', + 'ask.interruptedDetail': '本轮已中断,未提交回答', 'ask.answered': '{answered}/{total} 已回答', + 'ask.skipped': '未回答', 'bash.running': '运行中', 'bash.failed': '失败', 'bash.stopped': '已停止', @@ -221,8 +224,11 @@ export const en = { 'ask.rowTitle': 'Ask question', 'ask.waiting': 'waiting', 'ask.cancelled': 'cancelled', + 'ask.cancelledDetail': 'This question set was cancelled before answers were submitted.', 'ask.interrupted': 'interrupted', + 'ask.interruptedDetail': 'This question set was interrupted before answers were submitted.', 'ask.answered': '{answered}/{total} answered', + 'ask.skipped': 'Not answered', 'bash.running': 'Running', 'bash.failed': 'Failed', 'bash.stopped': 'Stopped', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index ede6905289..11f131475f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 4a5c240cfcb6bad1b4b1c8917dcdbc54fd0ce7b9 -README.zh.md: b243b18cb969cdc1c505884ba8bbfbbd7bc3be41 +README.md: 792a70b7b3cd2d7a48187e872104bffac1d54202 +README.zh.md: 0feeba622cd4bcee1d5affff252e7596b7190338 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 4a5c240cfc..792a70b7b3 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo ### Built-in views -This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. Unsupported or malformed inputs fall back to flattened Tool result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), and [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index b243b18cb9..0feeba622c 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 内置视图 -本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。不受支持或格式错误的输入回退为压平的工具结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md) 与 [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index 8102965a6d..d38bc55c9c 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -37,6 +37,8 @@ export interface ToolRowProps { body: string | null /** Flattened result text for the expanded Output section; null/absent = no output section. */ output?: string | null | undefined + /** Tool-owned structured body that replaces the generic input/output sections. */ + structuredBody?: ReactNode | null | undefined /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ @@ -91,6 +93,7 @@ export function ToolRow({ summarySuffix, body, output, + structuredBody, errorSummary, terminal, diff, @@ -115,8 +118,9 @@ export function ToolRow({ const readBody = read ?? null const searchBody = search ?? null const webBody = web ?? null + const ownedBody = structuredBody ?? null const outputText = output ?? null - const card = terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = ownedBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -183,67 +187,69 @@ export function ToolRow({ )} >
- {terminalBody !== null - ? ( - - ) - : diffBody !== null - ? - : readBody !== null - ? - : searchBody !== null - ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - - ) - : webBody !== null - ? - : ( + {ownedBody !== null + ? ownedBody + : terminalBody !== null + ? ( + + ) + : diffBody !== null + ? + : readBody !== null + ? + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && ( + + ))} + + ) + })} +
{state.groups.map(group => (group.status === 'ready' && group.items.length === 0) ? null : ( diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index b69d423213..7808f32589 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -17,7 +17,8 @@ import { detectTrigger } from '../core/detect.ts' import { MENU_CLOSED, menuReduce, seedGroups } from '../core/menu.ts' import type { MenuEvent, MenuState, TriggerHit } from '../core/contract.ts' import type { - ClientSessionContext, InputTriggerSource, PickAction, SubmitEnvelope, TriggerChar, TriggerGuard, + ClientSessionContext, InputTriggerCandidate, InputTriggerCrumb, InputTriggerSource, PickAction, + SubmitEnvelope, TriggerChar, TriggerGuard, } from '../types.ts' /** Roster access the controller borrows from the root service (registration order preserved). */ @@ -50,6 +51,14 @@ export class InputTriggerController { * for the launcher's expanded state without owning a second menu model. */ readonly launcher: SnapshotStore = createSnapshotStore(null) + /** + * Crumbs published by each header-bearing source for the open menu, keyed + * by source name. A snapshot store like {@link InputTriggerController.launcher}: + * the answer changes with every hit, and render-side consumers subscribe + * instead of re-polling sources during a render. + */ + readonly headers: SnapshotStore> = + createSnapshotStore>(new Map()) /** * Aggregated hot reference lexicon, grouped by trigger (plain-text-reference decision; * see .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): @@ -65,6 +74,8 @@ export class InputTriggerController { /** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */ private hit: TriggerHit | null = null + /** Whether the open menu was reached by a drill pick; cleared with the menu. */ + private drilled = false private fetch: AbortController | null = null private disposed = false /** Per-source lexicon unsubscribers (sources without the hook never enter). */ @@ -119,6 +130,7 @@ export class InputTriggerController { this.menu.set(seedGroups(this.menu.getSnapshot(), roster)) } this.reduce({ type: 'hit', hit }) + this.refreshHeaders(hit, roster) this.fetchCandidates(hit, roster) } @@ -146,6 +158,7 @@ export class InputTriggerController { this.launcher.set(source) this.menu.set(seedGroups(this.menu.getSnapshot(), [match])) this.reduce({ type: 'hit', hit }) + this.refreshHeaders(hit, [match]) this.fetchCandidates(hit, [match]) } @@ -165,17 +178,24 @@ export class InputTriggerController { if (candidate === undefined) return const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source) if (src === undefined) return - const outcome = src.onPick({ - candidate, - session: this.project(), - position: hit.position, - via: 'menu', - action, - span: hit.span, - }) - this.stopFetch() - this.reduce({ type: 'close' }) - this.execute(outcome, hit.span) + this.settle(src, candidate, hit, action) + } + + /** + * Pointer pick on one crumb of a source's menu header: route it through the + * same drill path a folder row takes, so returning to a step and descending + * into one share one outcome. + * @param source - source (group) name. + * @param index - crumb index within that source's published header. + */ + pickCrumb(source: string, index: number): void { + const hit = this.hit + if (this.disposed || !this.menu.getSnapshot().open || hit === null) return + const crumb = this.headers.getSnapshot().get(source)?.[index] + if (crumb === undefined || crumb.current === true) return + const src = this.deps.roster.sources(hit.trigger).find(s => s.name === source) + if (src === undefined) return + this.settle(src, { name: crumb.label, value: crumb.value }, hit, 'drill') } /** @@ -416,6 +436,7 @@ export class InputTriggerController { query: hit.query, quoted: hit.quoted, position: hit.position, + drilled: this.drilled, signal: controller.signal, }) .then( @@ -437,6 +458,66 @@ export class InputTriggerController { this.fetch = null } + /** + * Run one candidate (or crumb) through its source and apply the outcome. + * + * A drill is the one pick that leaves the menu open, so it is also the one + * that records how the next query was reached; every other pick closes the + * menu, which clears that record. + * @param src - the owning source. + * @param candidate - the picked candidate, or a crumb projected as one. + * @param hit - the authoritative hit supplying position and span CAS. + * @param action - settling pick or drill. + */ + private settle( + src: InputTriggerSource, + candidate: InputTriggerCandidate, + hit: TriggerHit, + action: PickAction, + ): void { + const outcome = src.onPick({ + candidate, + session: this.project(), + position: hit.position, + via: 'menu', + action, + span: hit.span, + }) + this.stopFetch() + this.reduce({ type: 'close' }) + // After the close above, so the reducer's own teardown cannot clear it: + // the drilled query arrives on the next track() call. + this.drilled = action === 'drill' + this.execute(outcome, hit.span) + } + + /** Re-poll every header-bearing source in the hit roster and publish their crumbs. */ + private refreshHeaders(hit: TriggerHit, roster: readonly InputTriggerSource[]): void { + const projection = this.project() + const crumbs = new Map() + for (const src of roster) { + if (src.header === undefined) continue + let published: readonly InputTriggerCrumb[] | undefined + try { + published = src.header(projection, { query: hit.query, quoted: hit.quoted, drilled: this.drilled }) + } catch (error) { + // A faulty source drops silently with a console record (the + // candidate-fetch failure policy); a header is decoration and must + // not take down the menu that carries the candidates. + console.error(`[ui-input-trigger] source "${src.name}" header failed:`, error) + continue + } + if (published === undefined || published.length === 0) continue + crumbs.set(src.name, published) + } + this.setHeaders(crumbs) + } + + private setHeaders(next: ReadonlyMap): void { + if (this.headers.getSnapshot().size === 0 && next.size === 0) return + this.headers.set(next) + } + private clearLauncher(): void { if (this.launcher.getSnapshot() !== null) this.launcher.set(null) } @@ -445,6 +526,9 @@ export class InputTriggerController { const cur = this.menu.getSnapshot() const next = menuReduce(cur, ev) if (next !== cur) this.menu.set(next) - if (!next.open) this.clearLauncher() + if (next.open) return + this.clearLauncher() + this.drilled = false + this.setHeaders(new Map()) } } diff --git a/packages/client/ui-input-trigger/src/client/index.ts b/packages/client/ui-input-trigger/src/client/index.ts index 4c586bc7e0..4860b5ce9d 100644 --- a/packages/client/ui-input-trigger/src/client/index.ts +++ b/packages/client/ui-input-trigger/src/client/index.ts @@ -23,9 +23,10 @@ export type { MenuViewProps } from './MenuView.tsx' export type { MenuKey } from './locales.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CandidateRequest, ClientSessionContext, - CommandClaim, ConsumeTokenRequest, InsertReferenceRequest, PickOutcome, PickVia, ReferenceCodec, - ReferenceInsert, InputTriggerCandidate, InputTriggerPick, InputTriggerSource, SubmitEnvelope, - SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition, + CommandClaim, ConsumeTokenRequest, HeaderRequest, InsertReferenceRequest, PickOutcome, PickVia, + ReferenceCodec, ReferenceInsert, InputTriggerCandidate, InputTriggerCrumb, InputTriggerPick, + InputTriggerSource, SubmitEnvelope, SubmitImageAttachment, SubmitOutcome, TokenSpan, TriggerChar, + TriggerGuard, TriggerPosition, } from '../types.ts' export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' export type { InputTriggerServiceContract } from './contract.ts' @@ -74,7 +75,9 @@ export function apply(ctx: ClientContext): void { const controller = inputTriggers.sessionOf(actx) return { menu: controller.menu, + headers: controller.headers, onPick: (source, index, action) => { controller.pick(source, index, action) }, + onCrumb: (source, index) => { controller.pickCrumb(source, index) }, onHover: (source, index) => { controller.hover(source, index) }, onDismiss: () => { controller.dismiss() }, } diff --git a/packages/client/ui-input-trigger/src/client/locales.ts b/packages/client/ui-input-trigger/src/client/locales.ts index 4f689a3a3d..704edbd633 100644 --- a/packages/client/ui-input-trigger/src/client/locales.ts +++ b/packages/client/ui-input-trigger/src/client/locales.ts @@ -1,7 +1,7 @@ /** * `slash.menu` namespace dictionaries: group titles keyed by source name * (the lookup chain returns the key itself, so an unknown source shows its - * raw name), the pending row, and the listbox aria label. + * raw name), the pending row, and the listbox and header aria labels. */ /** Simplified Chinese dictionary (the key-set source of truth). */ @@ -13,6 +13,7 @@ export const zh = { 'drill.aria': '进入目录', 'drill.hint': '进入目录', 'drill.key': 'Tab', + 'crumbs.aria': '目录导航', 'suggestions.aria': '触发候选建议', } satisfies Record @@ -28,5 +29,6 @@ export const en = { 'drill.aria': 'Browse folder', 'drill.hint': 'Browse folder', 'drill.key': 'Tab', + 'crumbs.aria': 'Folder navigation', 'suggestions.aria': 'Trigger suggestions', } satisfies Record diff --git a/packages/client/ui-input-trigger/src/client/slots.ts b/packages/client/ui-input-trigger/src/client/slots.ts index 93dad04f03..d07fdc0e65 100644 --- a/packages/client/ui-input-trigger/src/client/slots.ts +++ b/packages/client/ui-input-trigger/src/client/slots.ts @@ -1,6 +1,6 @@ /** Slash-menu props for the Conversation-owned input overlay. */ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { PickAction } from '../types.ts' +import type { InputTriggerCrumb, PickAction } from '../types.ts' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { MenuState } from '../core/contract.ts' @@ -8,6 +8,8 @@ import type { MenuState } from '../core/contract.ts' export interface MenuViewInjected { /** The service's menu state store (read-only here; MenuView subscribes). */ menu: SnapshotStore + /** Crumbs published per source for the open menu; sources without a header never appear. */ + headers: SnapshotStore> /** * Pointer pick routed back through the service pipeline. * @param source - source (group) name. @@ -22,6 +24,12 @@ export interface MenuViewInjected { * @param index - candidate index within the group. */ onHover: (source: string, index: number) => void + /** + * Pointer pick on one header crumb, routed back through the source's drill path. + * @param source - source (group) name. + * @param index - crumb index within that source's published header. + */ + onCrumb: (source: string, index: number) => void /** Dismiss the menu (external pointer outside the composer area). */ onDismiss: () => void } diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index 1abde9de66..dd5539e0cb 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -61,6 +61,33 @@ export interface InputTriggerCandidate { readonly drill?: boolean } +/** + * One crumb of a source's menu header. The pipeline treats `value` as opaque + * and hands it straight back on pick, so a source names its own destinations. + */ +export interface InputTriggerCrumb { + /** Rendered text of this step. */ + readonly label: string + /** Opaque source-owned pick payload, returned through `onPick`. */ + readonly value: string + /** The step the menu is currently showing; rendered as the trailing, unclickable crumb. */ + readonly current?: boolean +} + +/** What a source needs to decide the header of the open menu. */ +export interface HeaderRequest { + /** Text between the trigger char and the caret, live-filtered. */ + readonly query: string + /** Whether the active @file token is an open quoted path. */ + readonly quoted?: boolean + /** + * True while the open menu was reached by a drill pick rather than typed. + * The pipeline owns this fact; what it means for a header is the source's + * to decide. + */ + readonly drilled: boolean +} + /** * Non-text composer submission state visible to enter adjudication. The * composer owns the actual attachment payloads; adjudication only needs their @@ -77,6 +104,8 @@ export interface CandidateRequest { /** Whether the active @file token is an open quoted path. */ readonly quoted?: boolean readonly position: TriggerPosition + /** Whether the open menu was reached by a drill pick rather than typed. */ + readonly drilled: boolean readonly signal: AbortSignal } @@ -125,6 +154,18 @@ export interface InputTriggerSource { /** Whether the menu renders the source-title row; defaults to true. */ readonly showGroupTitle?: boolean candidates(session: ClientSessionContext, req: CandidateRequest): Promise + /** + * Synchronous breadcrumb rendered above this source's group, re-polled on + * every hit. Implementing IS the participation claim; `undefined` means + * this request needs no header. A crumb pick routes back through + * {@link InputTriggerSource.onPick} with `action: 'drill'` and the crumb's + * `value` as the candidate value, so returning to a step and descending + * into one are the same outcome. + * @param session - stable session projection. + * @param req - the live query and how the menu reached it. + * @returns the crumbs to render, or undefined for no header. + */ + header?(session: ClientSessionContext, req: HeaderRequest): readonly InputTriggerCrumb[] | undefined /** Every pick lands here; claim/insert outcomes are executed by the pipeline via the scoped input events. */ onPick(pick: InputTriggerPick): PickOutcome /** Synchronous space-time adjudication over hot state only. `token` is the just-completed leading token (e.g. '/goal'). */ diff --git a/packages/client/ui-input-trigger/tests/apply.client.spec.ts b/packages/client/ui-input-trigger/tests/apply.client.spec.ts index 0b66b9366a..6b9160c6bf 100644 --- a/packages/client/ui-input-trigger/tests/apply.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/apply.client.spec.ts @@ -81,9 +81,13 @@ describe('apply', () => { ctx.sessions.scope(sid('a'))!, ) expect(injected.menu).toBe(controller.menu) + expect(injected.headers).toBe(controller.headers) // The pick face routes into the controller pipeline (closed menu → no-op). injected.onPick('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) + // The crumb face routes into the controller too (closed menu → no-op). + injected.onCrumb('command', 0) + expect(controller.menu.getSnapshot().open).toBe(false) // The hover face routes into the controller too (closed menu → no-op). injected.onHover('command', 0) expect(controller.menu.getSnapshot().open).toBe(false) diff --git a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx index 48b5d52136..b174cb8a35 100644 --- a/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx +++ b/packages/client/ui-input-trigger/tests/menu-view.client.spec.tsx @@ -13,7 +13,9 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' 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 '../src/client/locales.ts' -import type { MenuState, TriggerHit } from '@deepseek-ai/dsh-client-ui-input-trigger/client' +import type { + InputTriggerCrumb, MenuState, TriggerHit, +} from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { MenuView } from '../src/client/MenuView.tsx' const hit: TriggerHit = { @@ -57,13 +59,32 @@ afterEach(() => { // unknown source comes back verbatim (its raw name). const t = makeTranslate(zh, commonZh) -function mount(state: MenuState) { +function mount(state: MenuState, crumbs: ReadonlyMap = new Map()) { const menu = createSnapshotStore(state) + const headers = createSnapshotStore>(crumbs) const onPick = vi.fn() + const onCrumb = vi.fn() const onHover = vi.fn() const onDismiss = vi.fn() - const view = render() - return { menu, onPick, onHover, onDismiss, view } + const view = render( + , + ) + return { menu, headers, onPick, onCrumb, onHover, onDismiss, view } +} + +/** The bounded menu shell: it owns the height clamp, the listbox scrolls inside it. */ +function menuShell(): HTMLElement { + const shell = document.querySelector('[data-trigger-menu]') + if (!(shell instanceof HTMLElement)) throw new Error('menu shell is not rendered') + return shell } /** The non-interactive group title rows (role=presentation), in document order. */ @@ -188,23 +209,23 @@ describe('MenuView', () => { it('caps the list height at the design maximum when the composer sits low enough', () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 800 } as DOMRect) mount(openState()) - expect(screen.getByRole('listbox').style.maxHeight).toBe('320px') + expect(menuShell().style.maxHeight).toBe('320px') }) it('clamps the list height to the space above the composer minus the safe margin', () => { vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ bottom: 200 } as DOMRect) mount(openState()) - expect(screen.getByRole('listbox').style.maxHeight).toBe('188px') + expect(menuShell().style.maxHeight).toBe('188px') }) it('re-fits the height when the window resizes', () => { const rect = vi.spyOn(Element.prototype, 'getBoundingClientRect') rect.mockReturnValue({ bottom: 800 } as DOMRect) mount(openState()) - expect(screen.getByRole('listbox').style.maxHeight).toBe('320px') + expect(menuShell().style.maxHeight).toBe('320px') rect.mockReturnValue({ bottom: 100 } as DOMRect) act(() => { window.dispatchEvent(new Event('resize')) }) - expect(screen.getByRole('listbox').style.maxHeight).toBe('88px') + expect(menuShell().style.maxHeight).toBe('88px') }) it('pointerdown outside the menu (no composer card ancestor) dismisses', () => { @@ -224,7 +245,15 @@ describe('MenuView', () => { const onDismiss = vi.fn() render(
- + >(new Map())} + onPick={vi.fn()} + onCrumb={vi.fn()} + onHover={vi.fn()} + onDismiss={onDismiss} + t={t} + />
, ) @@ -268,4 +297,36 @@ describe('MenuView', () => { fireEvent.mouseMove(options[0]!) expect(onHover).not.toHaveBeenCalled() }) + + it('renders a source header as a breadcrumb above the list, current step last', () => { + mount(openState(), new Map([['command', [ + { label: 'Workspace', value: 'root' }, + { label: 'src', value: 'src' }, + { label: 'module1', value: 'module1', current: true }, + ]]])) + const nav = screen.getByRole('navigation', { name: '目录导航' }) + expect([...nav.querySelectorAll('button')].map(button => button.textContent)) + .toEqual(['Workspace', 'src', 'module1']) + // The listbox holds options alone; the header is its sibling, not a row. + expect(screen.getByRole('listbox').contains(nav)).toBe(false) + }) + + it('mousedown on a crumb routes (source, index) without stealing focus; the current step is inert', () => { + const { onCrumb } = mount(openState(), new Map([['command', [ + { label: 'Workspace', value: 'root' }, + { label: 'src', value: 'src', current: true }, + ]]])) + const crumbs = screen.getByRole('navigation', { name: '目录导航' }).querySelectorAll('button') + expect(fireEvent.mouseDown(crumbs[0]!)).toBe(false) + expect(onCrumb).toHaveBeenCalledWith('command', 0) + onCrumb.mockClear() + expect(crumbs[1]!.disabled).toBe(true) + fireEvent.mouseDown(crumbs[1]!) + expect(onCrumb).not.toHaveBeenCalled() + }) + + it('renders no header for a source that published no crumbs', () => { + mount(openState()) + expect(screen.queryByRole('navigation')).toBeNull() + }) }) diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index b6849cfb4c..157c6c4a6e 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -572,6 +572,114 @@ describe('pick / scoped input events', () => { }) }) +describe('header / drilled descent', () => { + /** A source that publishes one crumb per path segment of a drilled query. */ + function crumbSource() { + const requests: Array<{ query: string; quoted?: boolean; drilled: boolean }> = [] + const picks: InputTriggerPick[] = [] + const source: InputTriggerSource = { + trigger: '@', + name: 'reference', + candidates: () => Promise.resolve([{ name: 'src', drill: true, value: 'src' }]), + header: (_session, req) => { + requests.push({ ...req }) + if (!req.drilled || !req.query.includes('/')) return undefined + return req.query.split('/').filter(Boolean).map(label => ({ label, value: label })) + }, + onPick: (pick) => { + picks.push(pick) + return pick.action === 'drill' ? { text: `@${String(pick.candidate.value)}/`, continue: true } : undefined + }, + } + return { source, requests, picks } + } + + it('publishes no crumbs for a typed path and asks every source how the menu was reached', async () => { + const { source, requests } = crumbSource() + const { controller } = controllerBench([source]) + controller.track('@src/', 5, { tier: 'plain' }, 1) + await tick() + expect(requests).toEqual([{ query: 'src/', drilled: false, quoted: false }]) + expect(controller.headers.getSnapshot().size).toBe(0) + }) + + it('publishes crumbs once a drill produced the query, and drops them when the menu closes', async () => { + const { source, picks } = crumbSource() + const { controller, actx } = controllerBench([source]) + const texts: string[] = [] + actx.on('slash/input-insert-text', (req) => { + texts.push(req.text) + return true + }) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + expect(texts).toEqual(['@src/']) + expect(picks[0]?.action).toBe('drill') + // The drilled text lands as the next tracked draft. + controller.track('@src/', 5, { tier: 'plain' }, 2) + await tick() + expect(controller.headers.getSnapshot().get('reference')).toEqual([{ label: 'src', value: 'src' }]) + controller.dismiss() + expect(controller.headers.getSnapshot().size).toBe(0) + }) + + it('routes a crumb through the source drill path and refuses the current step', async () => { + const { source, picks } = crumbSource() + const { controller } = controllerBench([source]) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + controller.track('@src/lib/', 9, { tier: 'plain' }, 2) + await tick() + const trail = controller.headers.getSnapshot().get('reference') + expect(trail?.map(crumb => crumb.label)).toEqual(['src', 'lib']) + picks.length = 0 + controller.pickCrumb('reference', 0) + expect(picks).toHaveLength(1) + expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' }) + }) + + it('drops a source whose header throws and keeps the rest of the menu', async () => { + const failing: InputTriggerSource = { + trigger: '@', + name: 'broken', + candidates: () => Promise.resolve([{ name: 'x' }]), + header: () => { throw new Error('header boom') }, + onPick: () => undefined, + } + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const { controller } = controllerBench([failing]) + controller.track('@x', 2, { tier: 'plain' }, 1) + await tick() + expect(controller.headers.getSnapshot().size).toBe(0) + expect(controller.menu.getSnapshot().open).toBe(true) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('tells candidate fetches how the menu was reached', async () => { + const seen: boolean[] = [] + const source: InputTriggerSource = { + trigger: '@', + name: 'reference', + candidates: (_session, req) => { + seen.push(req.drilled) + return Promise.resolve([{ name: 'src', drill: true, value: 'src' }]) + }, + onPick: pick => (pick.action === 'drill' ? { text: '@src/', continue: true } : undefined), + } + const { controller, actx } = controllerBench([source]) + actx.on('slash/input-insert-text', () => true) + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + controller.track('@src/', 5, { tier: 'plain' }, 2) + await tick() + expect(seen).toEqual([false, true]) + }) +}) + describe('lexicon', () => { function lexSource(trigger: TriggerChar, name: string, roll?: readonly string[] , hasHook = true): InputTriggerSource { return { diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index d4ba27bacc..8415fcf059 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -32,6 +32,8 @@ export { Tooltip } from './Tooltip.tsx' export type { TooltipSide } from './Tooltip.tsx' export { Toast } from './Toast.tsx' export { writeClipboard } from './clipboard.ts' +export { relativeTime } from './relative-time.ts' +export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' diff --git a/packages/client/ui-primitives/src/relative-time.ts b/packages/client/ui-primitives/src/relative-time.ts new file mode 100644 index 0000000000..f1d7f7e37e --- /dev/null +++ b/packages/client/ui-primitives/src/relative-time.ts @@ -0,0 +1,36 @@ +/** + * Compact relative-time bucketing shared by every surface that dates a + * session. Bucketing is here so two surfaces naming the same session agree; + * the words stay in each plugin's own dictionary, per locale-owned copy. + * + * @module @deepseek-ai/dsh-client-ui-primitives/relative-time + */ + +/** Relative-time bucket of a dated row's trailing label. */ +export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years' + +/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */ +export interface RelativeTime { + unit: RelativeTimeUnit + n: number +} + +/** + * Compact relative time, as a structured bucket the renderer localizes + * ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en). + * @param at - epoch ms of the dated moment. + * @param now - current epoch ms (injected for pure rendering). + * @returns the row's trailing time bucket and magnitude. + */ +export function relativeTime(at: number, now: number): RelativeTime { + const MIN = 60_000 + const HOUR = 3_600_000 + const DAY = 86_400_000 + const diff = Math.max(0, now - at) + if (diff < MIN) return { unit: 'now', n: 0 } + if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) } + if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) } + if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) } + if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) } + return { unit: 'years', n: Math.floor(diff / (365 * DAY)) } +} diff --git a/packages/client/ui-primitives/tests/relative-time.client.spec.ts b/packages/client/ui-primitives/tests/relative-time.client.spec.ts new file mode 100644 index 0000000000..2d482dd693 --- /dev/null +++ b/packages/client/ui-primitives/tests/relative-time.client.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' + +const MIN = 60_000 +const HOUR = 3_600_000 +const DAY = 86_400_000 +const now = 1_800_000_000_000 + +describe('relativeTime', () => { + it('buckets each distance and reports its magnitude', () => { + expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 }) + expect(relativeTime(now - 5 * MIN, now)).toEqual({ unit: 'minutes', n: 5 }) + expect(relativeTime(now - 3 * HOUR, now)).toEqual({ unit: 'hours', n: 3 }) + expect(relativeTime(now - 2 * DAY, now)).toEqual({ unit: 'days', n: 2 }) + expect(relativeTime(now - 60 * DAY, now)).toEqual({ unit: 'months', n: 2 }) + expect(relativeTime(now - 400 * DAY, now)).toEqual({ unit: 'years', n: 1 }) + }) + + it('reports the coarser bucket at each boundary', () => { + expect(relativeTime(now - MIN, now)).toEqual({ unit: 'minutes', n: 1 }) + expect(relativeTime(now - HOUR, now)).toEqual({ unit: 'hours', n: 1 }) + expect(relativeTime(now - DAY, now)).toEqual({ unit: 'days', n: 1 }) + expect(relativeTime(now - 30 * DAY, now)).toEqual({ unit: 'months', n: 1 }) + expect(relativeTime(now - 365 * DAY, now)).toEqual({ unit: 'years', n: 1 }) + }) + + it('reads a future moment as the present rather than a negative distance', () => { + expect(relativeTime(now + DAY, now)).toEqual({ unit: 'now', n: 0 }) + }) +}) diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 5b4307038e..6c3841170b 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/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-reference/README.md -README.md: 7980a68632b8dba45e5a1b9d13b7d4380bec6c22 -README.zh.md: bd9aad7855cc6fd1bbf9bae05cd0521bafaab7fd +README.md: 9c2655fcac96803ba47864c2ab99ed123ca8e201 +README.zh.md: 15f61ba1a692539e87bfe551225b346b78a203e7 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 7980a68632..9c2655fcac 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-reference` is the unified Web `@file` and `@session` reference source: it registers the `reference` entry in the composer's inline-suggestion machinery so a user typing `@` sees file and session candidates in one list. Files order before sessions, sections are labelled with locale-registered terms, and either candidate domain can fail independently without blocking the other. A pick inserts an atomic inline reference — file, folder, and session alike — whose hidden serialized and clipboard form is the natural text the shared `@path` grammar defines; a directory row additionally carries a drill verb (Tab or the row's chevron) that keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Selecting a session routes through the session-reference service, which validates the mention and captures model context at the pre-step boundary; this package itself registers no prompt or tool. +`dsh-client-ui-reference` is the unified Web `@file` and `@session` reference source: it registers the `reference` entry in the composer's inline-suggestion machinery so a user typing `@` sees file and session candidates in one list. Files order before sessions, sections are labelled with locale-registered terms, and either candidate domain can fail independently without blocking the other. Each row carries only what distinguishes it: a file names its parent directory and nothing at the workspace root, a session names its workspace only when that workspace is not the current one, and a drilled directory listing names none because its breadcrumb already does. A pick inserts an atomic inline reference — file, folder, and session alike — whose hidden serialized and clipboard form is the natural text the shared `@path` grammar defines; a directory row additionally carries a drill verb (Tab or the row's chevron) that keeps plain editable path text and the menu active at its trailing slash so the user can descend another level. Selecting a session routes through the session-reference service, which validates the mention and captures model context at the pre-step boundary; this package itself registers no prompt or tool. ## Table of Contents @@ -49,7 +49,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c ### Candidate flow -For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. +For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated with the same relative-time bucket the session list uses, so one session reads the same age on both surfaces. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. ### Serialization diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index bd9aad7855..15f61ba1a6 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-reference` 是统一的 Web `@file` 与 `@session` 引用 source:它把 `reference` 条目注册进编辑器的行内建议机制,让用户在输入 `@` 时于同一个列表中看到文件与会话候选。文件排在会话之前,分组标题使用注册在 locale 字典中的标签,任一候选领域失败都会独立降级、不阻塞另一领域。选择一项会插入原子行内引用——文件、文件夹与会话皆然——其隐藏的序列化与剪贴板形式就是共享 `@path` 语法所定义的自然文本;目录行额外携带一个钻取动词(Tab 或行尾 chevron),保持可编辑的路径纯文本并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。选择会话会经 session-reference 服务路由,该服务校验 mention 并在 pre-step 边界捕获模型上下文;本包自身不注册任何提示词或工具。 +`dsh-client-ui-reference` 是统一的 Web `@file` 与 `@session` 引用 source:它把 `reference` 条目注册进编辑器的行内建议机制,让用户在输入 `@` 时于同一个列表中看到文件与会话候选。文件排在会话之前,分组标题使用注册在 locale 字典中的标签,任一候选领域失败都会独立降级、不阻塞另一领域。每一行只承载能区分它的信息:文件显示其父目录、位于工作区根目录时不显示;会话仅在其工作区不是当前工作区时显示该工作区;下钻后的目录列表不显示位置,因为面包屑已经承载了它。选择一项会插入原子行内引用——文件、文件夹与会话皆然——其隐藏的序列化与剪贴板形式就是共享 `@path` 语法所定义的自然文本;目录行额外携带一个钻取动词(Tab 或行尾 chevron),保持可编辑的路径纯文本并让菜单在尾部斜杠处保持活跃,用户可以继续进入下一层。选择会话会经 session-reference 服务路由,该服务校验 mention 并在 pre-step 边界捕获模型上下文;本包自身不注册任何提示词或工具。 ## 目录 @@ -49,7 +49,7 @@ kind: "package-reference" ### 候选流程 -对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。 +对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行使用与会话列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 ### 序列化 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 340aa2bceb..aa1ce68bd3 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -33,6 +33,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" ], @@ -46,23 +47,28 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "files": [ diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index 392fe365f8..cafe481c56 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -3,6 +3,11 @@ * the cancellable generated Remote namespaces in parallel with deterministic * ordering and labels. * + * Rows carry only what distinguishes them: a file names its parent directory + * (nothing at the workspace root), a directory listing names none because its + * breadcrumb already does, and a session names its workspace only when that + * workspace is not the current one. + * * @module @deepseek-ai/dsh-client-ui-reference/client */ // Type-only: pulls the generated Remote API and ctx.remote merge through the Client assembly boundary. @@ -10,17 +15,21 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' import type { - ClientSessionContext, InputTriggerServiceContract, InputTriggerSource, + ClientSessionContext, InputTriggerCrumb, InputTriggerServiceContract, InputTriggerSource, } from '@deepseek-ai/dsh-client-ui-input-trigger/client' import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' import type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' +import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import { en, NS, zh, type ReferenceKey } from './locales.ts' /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ - 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + 'inputTriggers', 'locale', 'connection', 'remote', 'remote.fileReferences', + 'remote.sessionReferenceResolver', ] /** @@ -30,11 +39,12 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) + const connection = ctx.get('connection') as ConnectionHandle const source: InputTriggerSource = { trigger: '@', name: 'reference', showGroupTitle: false, - async candidates(session: ClientSessionContext, { query, quoted, signal }) { + async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) { const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], @@ -47,17 +57,26 @@ export function apply(ctx: ClientContext): void { ) const [fileItems, sessionItems] = await Promise.all([files, sessions]) if (signal.aborted) return [] + // The header already names the directory being listed; rows repeat it only + // when there is no header to carry it. + const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined + const now = Date.now() + const home = connection.hostDescription.getSnapshot()?.home return [ - ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, t)), - ...sessionItems.map(candidate => sessionCandidate(candidate, t)), + ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), + ...sessionItems.map(candidate => sessionCandidate(candidate, now, home, t)), ] }, + header(_session: ClientSessionContext, req) { + return crumbsFor(req.query, req.quoted === true, req.drilled, t) + }, onPick({ candidate, action }) { const value = parseCandidate(candidate.value) if (value?.kind === 'file') { // A directory row carries two verbs: the settling pick resolves the // folder itself as an atomic reference, while the drill action (Tab / - // row chevron) keeps the literal descent text and the open menu. + // row chevron / a header crumb) keeps the literal descent text and + // the open menu. if (value.fileKind === 'directory' && action === 'drill') { return { text: value.mention, continue: true } } @@ -93,16 +112,71 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => inputTriggers.registerSource(source), 'ui-reference: @ source') } -type Translate = (key: ReferenceKey) => string +type Translate = (key: ReferenceKey, params?: Record) => string type ReferenceCandidateValue = | { kind: 'file'; fileKind: FileReferenceCandidate['kind']; label: string; mention: string } | { kind: 'session'; label: string; mention: string } -function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean, t: Translate) { +/** + * The breadcrumb of a drilled directory listing, from the workspace root down + * to the directory being listed. + * + * Only a drill produces one: a path the user typed carries its own context in + * the draft, while a drill replaced the text they were reading with a deeper + * one and owes them the way back. + * @param query - the live query, path text following `@` or `@"`. + * @param quoted - whether the active token is an open quoted path. + * @param drilled - whether a drill pick, rather than typing, produced the query. + * @param t - the reference dictionary. + * @returns the crumbs, or undefined when this listing needs no header. + */ +function crumbsFor( + query: string, + quoted: boolean, + drilled: boolean, + t: Translate, +): readonly InputTriggerCrumb[] | undefined { + if (!drilled) return undefined + const slash = query.lastIndexOf('/') + if (slash < 0) return undefined + const segments = query.slice(0, slash).split('/').filter(segment => segment !== '') + const crumbs: InputTriggerCrumb[] = [{ + label: t('crumb.root'), + value: directoryValue(t('crumb.root'), quoted ? '@"' : '@'), + }] + for (const [index, segment] of segments.entries()) { + const path = segments.slice(0, index + 1).join('/') + const mention = formatFileMention({ path, kind: 'directory' }, quoted) + // A trail whose steps cannot all be written back as mention text would + // send the user somewhere they did not click; show no header instead. + if (mention === undefined) return undefined + crumbs.push({ + label: segment, + value: directoryValue(segment, mention), + ...(index === segments.length - 1 ? { current: true } : {}), + }) + } + return crumbs +} + +/** Project one directory destination as the drill payload `onPick` already understands. */ +function directoryValue(label: string, mention: string): string { + const value: ReferenceCandidateValue = { kind: 'file', fileKind: 'directory', label, mention } + return JSON.stringify(value) +} + +function fileCandidate( + candidate: FileReferenceCandidate, + preserveQuote: boolean, + withLocation: boolean, + t: Translate, +) { const mention = formatFileMention(candidate, preserveQuote) if (mention === undefined) return [] - const name = candidate.path.slice(candidate.path.lastIndexOf('/') + 1) + const slash = candidate.path.lastIndexOf('/') + const name = candidate.path.slice(slash + 1) + const parent = slash < 0 ? '' : candidate.path.slice(0, slash) const directory = candidate.kind === 'directory' const value: ReferenceCandidateValue = { kind: 'file', @@ -112,7 +186,9 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean } return [{ name: `${name}${directory ? '/' : ''}`, - description: candidate.path, + // The location is the parent alone: repeating the name the row already + // shows says nothing, and a workspace-root entry has no parent to name. + ...(withLocation && parent !== '' ? { description: parent } : {}), icon: directory ? 'folder' as const : 'file' as const, section: t('section.files'), value: JSON.stringify(value), @@ -120,9 +196,19 @@ function fileCandidate(candidate: FileReferenceCandidate, preserveQuote: boolean }] } -function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Translate) { - const location = candidate.cwd ?? t('candidate.noCwd') - const description = `${candidate.label === candidate.sessionId ? '' : `${candidate.sessionId} · `}${location} · ${new Date(candidate.createdAt).toISOString()}` +function sessionCandidate( + candidate: SessionReferenceMentionCandidate, + now: number, + home: string | undefined, + t: Translate, +) { + const { unit, n } = relativeTime(candidate.createdAt, now) + const age = unit === 'now' ? t('time.now') : t(`time.${unit}`, { n }) + // Candidates are ranked by workspace affinity, so the location only tells + // the user something when it is not the workspace they are already in. + const location = candidate.sameWorkspace + ? undefined + : candidate.cwd === undefined ? t('candidate.noCwd') : abbreviateHomePath(candidate.cwd, home) const value: ReferenceCandidateValue = { kind: 'session', label: candidate.label, @@ -130,7 +216,7 @@ function sessionCandidate(candidate: SessionReferenceMentionCandidate, t: Transl } return { name: candidate.label, - description, + description: location === undefined ? age : `${location} · ${age}`, icon: 'session' as const, section: t('section.sessions'), value: JSON.stringify(value), diff --git a/packages/client/ui-reference/src/client/locales.ts b/packages/client/ui-reference/src/client/locales.ts index e0885f9f3c..ed5f67a61d 100644 --- a/packages/client/ui-reference/src/client/locales.ts +++ b/packages/client/ui-reference/src/client/locales.ts @@ -5,11 +5,24 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots' /** Dictionary namespace owned by this plugin. */ export const NS = 'reference' -/** Simplified Chinese dictionary (the key-set source of truth). */ +/** + * Simplified Chinese dictionary (the key-set source of truth). + * + * The `time.*` bucket words are this namespace's own copy of the session-row + * vocabulary: locale-owned copy keeps the words per plugin, while the + * bucketing they name is the one shared {@link relativeTime} in ui-primitives. + */ export const zh = { 'section.files': '文件与文件夹', 'section.sessions': '对话', 'candidate.noCwd': '(无工作目录)', + 'crumb.root': '工作区', + 'time.now': '刚刚', + 'time.minutes': '{n}分钟', + 'time.hours': '{n}小时', + 'time.days': '{n}天', + 'time.months': '{n}个月', + 'time.years': '{n}年', } satisfies Record /** The reference namespace key union. */ @@ -27,4 +40,11 @@ export const en = { 'section.files': 'Files & folders', 'section.sessions': 'Sessions', 'candidate.noCwd': '(no cwd)', + 'crumb.root': 'Workspace', + 'time.now': 'now', + 'time.minutes': '{n}min', + 'time.hours': '{n}h', + 'time.days': '{n}d', + 'time.months': '{n}mo', + 'time.years': '{n}y', } satisfies Record diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index bfb6b919dc..51907a75b7 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -4,7 +4,7 @@ * round-trip, and registration lifecycle. */ import { Context, Service } from '@deepseek-ai/cordis' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { @@ -17,6 +17,17 @@ import { apply as nodeApply } from '../src/index.ts' const sid = (value: string): SessionId => value as SessionId const session: ClientSessionContext = { sessionId: sid('target') } +/** The target session's own workspace: candidates in it are the `sameWorkspace` rows. */ +const HOME = '/Users/dev' +const CREATED_AT = 1_700_000_000_000 +/** Three days after every fixture's createdAt, so age copy is one fixed bucket. */ +const NOW = CREATED_AT + 3 * 86_400_000 + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(NOW) +}) +afterEach(() => { vi.useRealTimers() }) type RemoteEnvelope = | { ok: true; value: T } @@ -36,6 +47,7 @@ function request( query, quoted: options.quoted ?? false, position: 'inline', + drilled: false, signal: options.signal ?? new AbortController().signal, } } @@ -53,8 +65,9 @@ async function bench( value: [{ sessionId: sid('source'), label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, + cwd: `${HOME}/project`, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })), @@ -76,6 +89,7 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() if (source === undefined) throw new Error('reference source was not registered') @@ -85,7 +99,8 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ - 'inputTriggers', 'locale', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', + 'inputTriggers', 'locale', 'connection', 'remote', 'remote.fileReferences', + 'remote.sessionReferenceResolver', ]) const { fiber } = await bench() let registered: InputTriggerSource | undefined @@ -105,6 +120,7 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) + ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false }) @@ -142,6 +158,7 @@ describe('candidates', () => { sessionId: SessionId label: string cwd: string + sameWorkspace: boolean createdAt: number mention: string }[] @@ -152,8 +169,9 @@ describe('candidates', () => { value: [{ sessionId: sid('source'), label: 'Research', - cwd: '/project', - createdAt: 1_700_000_000_000, + cwd: `${HOME}/project`, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[Research](dsh-session:InNvdXJjZSI)', }], }) @@ -166,21 +184,22 @@ describe('candidates', () => { releaseSessions() releaseFiles() await expect(pending).resolves.toEqual([ - expect.objectContaining({ + { name: 'src/', - description: 'src', icon: 'folder', section: 'Files & folders', - }), + value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'src', mention: '@src/' }), + drill: true, + }, expect.objectContaining({ name: 'a b.md', - description: 'docs/a b.md', + description: 'docs', icon: 'file', section: 'Files & folders', }), expect.objectContaining({ name: 'Research', - description: 'source · /project · 2023-11-14T22:13:20.000Z', + description: '~/project · 3d', icon: 'session', section: 'Sessions', }), @@ -199,8 +218,9 @@ describe('candidates', () => { value: [{ sessionId: sid('source'), label: 'Research', - cwd: '/project', - createdAt: 0, + cwd: `${HOME}/project`, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })) @@ -258,14 +278,15 @@ describe('candidates', () => { await expect(source.candidates(session, request('bad'))).resolves.toEqual([]) }) - it('omits redundant session ids and labels sessions without a cwd', async () => { + it('labels a session without a cwd and still dates it', async () => { const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [{ sessionId: sid('same'), label: 'same', - createdAt: 0, + sameWorkspace: false, + createdAt: CREATED_AT, mention: '@[same](dsh-session:InNhbWUi)', }], })) @@ -273,10 +294,120 @@ describe('candidates', () => { await expect(source.candidates(session, request('same'))).resolves.toEqual([ expect.objectContaining({ name: 'same', - description: '(no cwd) · 1970-01-01T00:00:00.000Z', + description: '(no cwd) · 3d', }), ]) }) + + it('reads a session opened moments ago as the present, not a zero distance', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('just-now'), + label: 'Just now', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: NOW - 1_000, + mention: '@[Just now](dsh-session:Imp1c3Qtbm93Ig)', + }], + })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('just'))).resolves.toEqual([ + expect.objectContaining({ name: 'Just now', description: 'now' }), + ]) + }) + + it('dates a session in the current workspace without repeating that workspace', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('sibling'), + label: 'Sibling run', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Sibling run](dsh-session:InNpYmxpbmdyIg)', + }], + })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, request('sib'))).resolves.toEqual([ + expect.objectContaining({ name: 'Sibling run', description: '3d' }), + ]) + }) +}) + +describe('directory header', () => { + const drilledRequest = (query: string, quoted = false): CandidateRequest => ({ + query, + quoted, + position: 'inline', + drilled: true, + signal: new AbortController().signal, + }) + + it('publishes no header for a query the user typed', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'src/module1/', drilled: false })).toBeUndefined() + }) + + it('publishes no header until a drilled query names a directory', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'src', drilled: true })).toBeUndefined() + }) + + it('trails the workspace root down to the directory being listed', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'src/module1/ind', drilled: true })).toEqual([ + { label: 'Workspace', value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'Workspace', mention: '@' }) }, + { label: 'src', value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'src', mention: '@src/' }) }, + { + label: 'module1', + value: JSON.stringify({ kind: 'file', fileKind: 'directory', label: 'module1', mention: '@src/module1/' }), + current: true, + }, + ]) + }) + + it('keeps an open quote across every crumb of a quoted descent', async () => { + const { source } = await bench() + const crumbs = source.header?.(session, { query: 'my dir/sub/', quoted: true, drilled: true }) + expect(crumbs?.map(crumb => JSON.parse(crumb.value) as { mention: string }).map(value => value.mention)) + .toEqual(['@"', '@"my dir/', '@"my dir/sub/']) + }) + + it('publishes no header when a segment cannot be written back as mention text', async () => { + const { source } = await bench() + expect(source.header?.(session, { query: 'ok/we\u0001ird/', drilled: true })).toBeUndefined() + }) + + it('returns to a crumb through the same drill outcome a folder row uses', async () => { + const { source } = await bench() + const crumbs = source.header?.(session, { query: 'src/module1/', drilled: true }) + expect(source.onPick({ + candidate: { name: 'src', value: crumbs?.[1]?.value ?? '' }, + session, + position: 'inline', + via: 'menu', + action: 'drill', + span: { start: 0, end: 13, draftRev: 1 }, + })).toEqual({ text: '@src/', continue: true }) + }) + + it('drops the row location a drilled listing already shows in its header', async () => { + const files = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ path: 'src/module1/index.html', kind: 'file' as const }], + })) + const sessions = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const { source } = await bench(files, sessions) + await expect(source.candidates(session, drilledRequest('src/module1/'))).resolves.toEqual([ + expect.objectContaining({ name: 'index.html', icon: 'file' }), + ]) + const [row] = await source.candidates(session, drilledRequest('src/module1/')) + expect(row).not.toHaveProperty('description') + }) }) describe('pick and codec', () => { diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index cd661610fa..bc35a964c1 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -26,12 +26,21 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/workspace-path" + }, + { + "path": "../connection/tsconfig.client.json" + }, { "path": "../locale" }, { "path": "../ui-input-trigger" }, + { + "path": "../ui-primitives" + }, { "path": "../ui-slots" } diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 2f59d19c42..c4f2e3ea63 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -103,7 +103,7 @@ const sid = (id: string) => id as SessionId const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) }) const req = (query: string, signal?: AbortSignal) => - ({ query, position: 'leading' as const, signal: signal ?? new AbortController().signal }) + ({ query, position: 'leading' as const, drilled: false, signal: signal ?? new AbortController().signal }) describe('apply', () => { it('declares the services it binds', () => { diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 074f51a13c..012c2d6869 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -10,13 +10,12 @@ import clsx from 'clsx' import { HoverCard, IconArchiveOutline20, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16, - IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, + IconTrashOutline16, IconTriangleRightFill14, Menu, relativeTime, StateDot, } from '@deepseek-ai/dsh-client-ui-primitives' import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceBrowserProps } from '../contract/slots.ts' import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts' -import { relativeTime } from '../tree.ts' import css from './Rows.module.css' /** The standard locale seat, prop-passed from the browser root. */ diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 01c6ad644b..4445f78edb 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -325,15 +325,6 @@ export function deriveFlat( return rows.map(session => sessionNode(session, descendants, pendingInteractions)) } -/** Relative-time bucket of a session row's trailing label. */ -export type RelativeTimeUnit = 'now' | 'minutes' | 'hours' | 'days' | 'months' | 'years' - -/** Structured relative time: the bucket plus its magnitude (0 for 'now'). */ -export interface RelativeTime { - unit: RelativeTimeUnit - n: number -} - /** * Merge immediate title/Workspace substring matches with ranked Host content * matches. Local rows lead newest-first, content-only rows retain backend @@ -422,23 +413,3 @@ export function deriveSearchResults( hasMore: content.hasMore || ordered.length > limit, } } - -/** - * Compact relative time for session rows, as a structured bucket the - * renderer localizes ("now"/"5min"/"3h"/"2d"/"4mo"/"1y" in en). - * @param updatedAt - epoch ms of the session's last activity. - * @param now - current epoch ms (injected for pure rendering). - * @returns the row's trailing time bucket and magnitude. - */ -export function relativeTime(updatedAt: number, now: number): RelativeTime { - const MIN = 60_000 - const HOUR = 3_600_000 - const DAY = 86_400_000 - const diff = Math.max(0, now - updatedAt) - if (diff < MIN) return { unit: 'now', n: 0 } - if (diff < HOUR) return { unit: 'minutes', n: Math.floor(diff / MIN) } - if (diff < DAY) return { unit: 'hours', n: Math.floor(diff / HOUR) } - if (diff < 30 * DAY) return { unit: 'days', n: Math.floor(diff / DAY) } - if (diff < 365 * DAY) return { unit: 'months', n: Math.floor(diff / (30 * DAY)) } - return { unit: 'years', n: Math.floor(diff / (365 * DAY)) } -} diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 093ac22b1d..6045dd00d9 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -4,7 +4,7 @@ import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace- import type { SessionPendingInteractionBase } from '@deepseek-ai/dsh-client-ui-session/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { - deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, relativeTime, + deriveFlat, deriveGroups, deriveSearchResults, workspaceLabel, UNGROUPED_KEY, } from '../src/client/tree.ts' import { createWorkspaceViewStore } from '../src/client/stores.ts' @@ -486,15 +486,3 @@ describe('workspaceLabel', () => { expect(workspaceLabel('/')).toBe('/') }) }) - -describe('relativeTime', () => { - it('buckets current, minute, hour, day, month, and year distances', () => { - const now = 400 * 24 * 60 * 60 * 1_000 - expect(relativeTime(now, now)).toEqual({ unit: 'now', n: 0 }) - expect(relativeTime(now - 5 * 60_000, now)).toEqual({ unit: 'minutes', n: 5 }) - expect(relativeTime(now - 3 * 3_600_000, now)).toEqual({ unit: 'hours', n: 3 }) - expect(relativeTime(now - 2 * 86_400_000, now)).toEqual({ unit: 'days', n: 2 }) - expect(relativeTime(now - 60 * 86_400_000, now)).toEqual({ unit: 'months', n: 2 }) - expect(relativeTime(0, now)).toEqual({ unit: 'years', n: 1 }) - }) -}) diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml index 22d085621b..b665089a8f 100644 --- a/packages/context/file-reference-local/README.i18n.yaml +++ b/packages/context/file-reference-local/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/context/file-reference-local/README.md -README.md: a05ff98d8bd5c1cadaa3759d248712400f09b723 -README.zh.md: df9950e4f473e35b691758c107ecd06ab48e6ce7 +README.md: 7a0fff671f7ecc8b48b16e86ea71a3c0071a0e38 +README.zh.md: 5b01e7c9b10676f89aecdc6a74c5ea5b8b155f3e diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md index a05ff98d8b..7a0fff671f 100644 --- a/packages/context/file-reference-local/README.md +++ b/packages/context/file-reference-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Agents and their host UIs get ranked path candidates for `@file` mentions, scoped to each agent's workspace and bounded so even large repositories stay responsive. `dsh-file-reference-local` implements `ctx.fileReferences` for the local filesystem: it keeps one reusable search index per agent, invalidates it after tool results so completion reflects workspace changes, and never follows directory symlinks. When the addressed agent can call `read`, it also installs a stable one-sentence guidance into the system prompt. Choose it when the agent's `read` tool operates on the Harness host filesystem; remote or virtual namespaces need a provider whose discovery matches the tool. +Agents and their host UIs get ranked path candidates for `@file` mentions, scoped to each agent's workspace and bounded so even large repositories stay responsive. `dsh-file-reference-local` implements `ctx.fileReferences` for the local filesystem: it keeps one reusable search index per agent, rebuilds it in the background after tool results so completion reflects workspace changes without stalling, and never follows directory symlinks. When the addressed agent can call `read`, it also installs a stable one-sentence guidance into the system prompt. Choose it when the agent's `read` tool operates on the Harness host filesystem; remote or virtual namespaces need a provider whose discovery matches the tool. ## Table of Contents @@ -39,15 +39,15 @@ The defaults suit a typical workspace, so the minimal mount needs no configurati ### What you get -Typing `@` in a host UI returns up to `maxResults` ranked path candidates for the addressed agent. A query containing `/` lists the matching directory's entries directly; a bare query fuzzy-ranks the bounded recursive index. Directory candidates keep the mention open with a trailing slash. After any tool result, the agent's reusable index is invalidated, so later completion observes workspace mutations; an unchanged agent keeps its index across queries. +Typing `@` in a host UI returns up to `maxResults` ranked path candidates for the addressed agent. A query containing `/` lists the matching directory's entries directly; a bare query fuzzy-ranks the bounded recursive index. Directory candidates keep the mention open with a trailing slash. After any tool result the agent's index is marked stale: the next query still answers from it and its replacement builds in the background, so a rebuild never sits in front of the caret. ### Configuration | Field | Default | Meaning | |---|---|---| | `maxResults` | `20` | Maximum ranked candidates returned for one query | -| `maxEntries` | `10000` | Maximum files and directories indexed per agent workspace | -| `excludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and candidates | +| `maxEntries` | `50000` | Maximum files and directories indexed per agent workspace | +| `excludedDirectories` | `['.git', 'node_modules', 'lib', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates | Every numeric value must be a positive safe integer, and every excluded name must be a non-empty basename without `/` or `\`. @@ -63,19 +63,19 @@ This section explains the design of the provider; the observable behavior is cov ### Design concept -The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal until the `@` interaction ends or a `tool/result` event invalidates it. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; agent disposal releases both the index and the prompt fiber. +The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at that session's `cwd`. Directory-scoped queries (`a/b/...`) list live directory state, while bare fuzzy queries share one bounded recursive traversal. Only a workspace's first bare query waits for that traversal; a `tool/result` event marks the settled entries stale, and the next bare query serves them while the replacement builds. The model guidance is a per-agent prompt section contributed only while the addressed agent has a `read` tool; agent disposal releases both the index and the prompt fiber. ### Source map | File | Role | |---|---| | [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`: config validation, per-agent searches, prompt install | -| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`: traversal, ranking, exclusion, invalidation | +| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`: traversal, ranking, exclusion, staleness and background rebuild | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the discovery contract | ### Main flow -A `list(agent, query, signal)` call either lists one directory's entries or waits on the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events invalidate the addressed agent's index so the next bare query observes a fresh tree; unreadable or excluded subtrees contribute no candidates. +A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree; unreadable or excluded subtrees contribute no candidates. @@ -114,7 +114,7 @@ Conditional and fixed: the one sentence is present while `read` is visible to th #### KV Cache effect -The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index invalidations do not. +The stable sentence joins the system-prompt prefix. Mounting or removing this provider, or changing whether `read` is visible, changes that prefix; queries, candidates, and index staleness do not. ## Known Limitations and Deferred Work @@ -124,7 +124,8 @@ The stable sentence joins the system-prompt prefix. Mounting or removing this pr These limits define when the provider is a poor fit. They are current package constraints. - **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool. -- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. +- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name build outputs by convention, so a workspace that keeps sources under one of those basenames must override `excludedDirectories`. +- **One invalidation of staleness** — a bare query answered right after a tool result reflects the tree as of the previous traversal; the following query sees the rebuild. - **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded. diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md index df9950e4f4..5b01e7c9b1 100644 --- a/packages/context/file-reference-local/README.zh.md +++ b/packages/context/file-reference-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选,范围限定在各自 agent 的工作区,并有界以保证大型仓库依然响应迅速。`dsh-file-reference-local` 在本地文件系统上实现 `ctx.fileReferences`:它为每个 agent 维护一个可复用的搜索索引,在工具结果后使索引失效,让补全反映工作区变化,且从不跟随目录符号链接。当指定 agent 可以调用 `read` 时,它还会向系统提示词安装一句稳定指引。当 agent 的 `read` 工具作用于 Harness 宿主文件系统时选择它;远程或虚拟命名空间需要发现能力与工具一致的提供方。 +agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选,范围限定在各自 agent 的工作区,并有界以保证大型仓库依然响应迅速。`dsh-file-reference-local` 在本地文件系统上实现 `ctx.fileReferences`:它为每个 agent 维护一个可复用的搜索索引,在工具结果后于后台重建索引,让补全反映工作区变化而不发生停顿,且从不跟随目录符号链接。当指定 agent 可以调用 `read` 时,它还会向系统提示词安装一句稳定指引。当 agent 的 `read` 工具作用于 Harness 宿主文件系统时选择它;远程或虚拟命名空间需要发现能力与工具一致的提供方。 ## 目录 @@ -39,15 +39,15 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选 ### 你能得到什么 -在宿主 UI 中输入 `@` 会为指定 agent 返回至多 `maxResults` 个排序路径候选。包含 `/` 的查询直接列出匹配目录的条目;裸查询对有界递归索引做模糊排序。目录候选以尾斜杠保持 mention 开放。任何工具结果之后,该 agent 的可复用索引都会失效,后续补全因此能观察到工作区变化;未变化的 agent 会在多次查询间保留其索引。 +在宿主 UI 中输入 `@` 会为指定 agent 返回至多 `maxResults` 个排序路径候选。包含 `/` 的查询直接列出匹配目录的条目;裸查询对有界递归索引做模糊排序。目录候选以尾斜杠保持 mention 开放。任何工具结果之后,该 agent 的索引会被标记为陈旧:下一次查询仍由它作答,其替代品在后台构建,因此重建不会挡在光标前面。 ### 配置 | 字段 | 默认值 | 含义 | |---|---|---| | `maxResults` | `20` | 单次查询返回的排序候选最大数量 | -| `maxEntries` | `10000` | 每个 agent 工作区建立索引的文件与目录最大数量 | -| `excludedDirectories` | `['.git', 'node_modules']` | 遍历与候选中排除的目录基名 | +| `maxEntries` | `50000` | 每个 agent 工作区建立索引的文件与目录最大数量 | +| `excludedDirectories` | `['.git', 'node_modules', 'lib', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 | 所有数值都必须是正的安全整数,所有排除名都必须是不含 `/` 或 `\` 的非空基名。 @@ -63,19 +63,19 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选 ### 设计理念 -提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历,直到 `@` 交互结束或 `tool/result` 事件使其失效。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;agent 释放时会同时释放索引与提示词 fiber。 +提供方为每个 agent 维护一个可复用的 `WorkspaceFileSearch`,以该会话的 `cwd` 为根。目录范围查询(`a/b/...`)列出实时目录状态,裸模糊查询共享一次有界递归遍历。只有一个工作区的首次裸查询会等待该遍历;`tool/result` 事件把已完成的条目标记为陈旧,下一次裸查询在替代品构建期间继续由它作答。模型指引是按 agent 的提示词段,仅在指定 agent 拥有 `read` 工具时贡献;agent 释放时会同时释放索引与提示词 fiber。 ### 源码地图 | 文件 | 职责 | |---|---| | [`src/index.ts`](src/index.ts) | `LocalFileReferenceService`:配置校验、按 agent 搜索、提示词安装 | -| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`:遍历、排序、排除、失效 | +| [`src/search.ts`](src/search.ts) | `WorkspaceFileSearch`:遍历、排序、排除、陈旧标记与后台重建 | | [`src/invariant.ts`](src/invariant.ts) | 发现约定的不变式伴生插件 | ### 主要流程 -`list(agent, query, signal)` 要么列出某个目录的条目,要么等待共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件使指定 agent 的索引失效,下一次裸查询因此观察到全新目录树;不可读或已排除的子目录不贡献候选。 +`list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树;不可读或已排除的子目录不贡献候选。 @@ -114,7 +114,7 @@ Tokens prefixed with @ are workspace paths the user explicitly referenced, relat #### KV Cache 影响 -该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引失效不会改变前缀。 +该稳定句子会加入系统提示词前缀。挂载或移除此提供方,或者改变 `read` 是否可见,都会改变该前缀;查询、候选项和索引陈旧标记不会改变前缀。 ## 已知限制与延期工作 @@ -124,7 +124,8 @@ Tokens prefixed with @ are workspace paths the user explicitly referenced, relat 这些限制说明该提供方何时不合适。它们是当前包约束。 - **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。 -- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。 +- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项按惯例命名构建产物,若工作区把源码放在其中某个基名下,需覆盖 `excludedDirectories`。 +- **一次失效的陈旧窗口**:紧接工具结果之后的模糊查询反映的是上一次遍历时的目录树;下一次查询才看到重建结果。 - **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。 diff --git a/packages/context/file-reference-local/src/search.ts b/packages/context/file-reference-local/src/search.ts index a0257a5d33..c797b7b737 100644 --- a/packages/context/file-reference-local/src/search.ts +++ b/packages/context/file-reference-local/src/search.ts @@ -15,9 +15,32 @@ export { activeAtToken, formatFileMention } from '@deepseek-ai/dsh-file-referenc /** Default maximum file and directory candidates rendered for one query. */ export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20 /** Default maximum entries retained in one workspace search index. */ -export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 10_000 -/** Directory basenames omitted from traversal unless the deployment overrides them. */ -export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = ['.git', 'node_modules'] as const +export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 50_000 +/** + * Directory basenames omitted from traversal unless the deployment overrides + * them: version-control and dependency stores plus the build outputs of the + * ecosystems this harness runs in. Generated files carry the basenames of the + * sources that produced them, so an unfiltered tree both spends the entry + * budget twice and ranks `lib/x.js` beside `src/x.ts` for every query. + */ +export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [ + '.git', + 'node_modules', + 'lib', + 'dist', + 'build', + 'out', + 'coverage', + 'target', + '.next', + '.nuxt', + '.turbo', + '.venv', + '__pycache__', + '.pytest_cache', + '.mypy_cache', + '.gradle', +] as const /** Resolved limits and exclusions for one workspace index. */ export interface FileSearchConfig { @@ -41,14 +64,25 @@ interface IndexGeneration { promise: Promise } +/** A completed traversal and the invalidation counter it observed at its start. */ +interface SettledIndex { + entries: IndexedPath[] + startedAt: number +} + /** * Cancellable, reusable fuzzy index rooted at one agent working directory. * Directory-scoped queries list live state; bare fuzzy queries share one - * bounded traversal until the `@` interaction ends or a tool result invalidates it. + * bounded traversal. Only the first query of a workspace waits for that + * traversal — an invalidated index keeps answering while its replacement + * builds behind the caret. */ export class WorkspaceFileSearch { private readonly excludedDirectories: ReadonlySet + private settled: SettledIndex | undefined private generation: IndexGeneration | undefined + /** Monotonic invalidation counter; a settled index below it is stale. */ + private invalidations = 0 private disposed = false constructor( @@ -83,7 +117,7 @@ export class WorkspaceFileSearch { const fragment = slash < 0 ? '' : query.slice(slash + 1) return this.listDirectory(directory, fragment, signal) } - const indexed = await waitForPromise(this.ensureIndex(), signal) + const indexed = await this.indexFor(signal) return rankCandidates( indexed.filter(candidate => visibleForGlobalQuery(candidate.path, query)), query, @@ -91,31 +125,71 @@ export class WorkspaceFileSearch { ) } - /** Discard the current index so the next bare query observes a fresh tree. */ + /** + * Mark the index stale so a later bare query observes a fresh tree. + * + * The stale entries are kept and keep answering: a rebuild costs one + * traversal of the whole workspace, and putting that in front of the caret + * is what a caller invalidating on every tool result would otherwise pay. + */ invalidate(): void { - this.generation?.controller.abort(new Error('file search index invalidated')) - this.generation = undefined + this.invalidations += 1 } /** Abort traversal and make later queries return no candidates. */ dispose(): void { if (this.disposed) return this.disposed = true - this.invalidate() + this.generation?.controller.abort(new Error('file search index disposed')) + this.generation = undefined + this.settled = undefined + } + + /** + * The entries a bare fuzzy query ranks. Only the first query of a workspace + * waits for a traversal; afterwards a stale index answers immediately and + * its replacement builds in the background. + * @param signal - cancels this caller's wait without killing a shared traversal. + * @returns indexed paths, at most one invalidation behind the tree. + */ + private async indexFor(signal: AbortSignal): Promise { + const settled = this.settled + if (settled === undefined) return waitForPromise(this.ensureIndex(), signal) + if (settled.startedAt < this.invalidations) { + void this.ensureIndex().catch(() => { + // A background refresh failure is not this caller's error: the stale + // entries still answer and `settled.startedAt` stays behind, so the + // next bare query starts a fresh attempt. + }) + } + return settled.entries } private ensureIndex(): Promise { if (this.generation !== undefined) return this.generation.promise const controller = new AbortController() + const startedAt = this.invalidations const generation = { controller, promise: Promise.resolve([] as IndexedPath[]), } satisfies IndexGeneration - generation.promise = this.scanWorkspace(controller.signal).catch((error: unknown) => { - /* v8 ignore next -- every owned abort clears `generation` synchronously; this only protects an unexpected scan failure */ - if (this.generation === generation) this.generation = undefined - throw error - }) + generation.promise = this.scanWorkspace(controller.signal).then( + (entries) => { + /* v8 ignore next -- disposal aborts this traversal, so it reaches the + * rejection handler instead; the guard only covers a scan that finished + * its last directory in the instant before the abort landed, and must + * not hand a disposed index its entries back. */ + if (this.disposed) return entries + this.generation = undefined + this.settled = { entries, startedAt } + return entries + }, + (error: unknown) => { + /* v8 ignore next -- dispose clears `generation` synchronously; this only protects an unexpected scan failure */ + if (this.generation === generation) this.generation = undefined + throw error + }, + ) this.generation = generation return generation.promise } diff --git a/packages/context/file-reference-local/tests/search.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts index 98bdd8cf66..e9cae870ec 100644 --- a/packages/context/file-reference-local/tests/search.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -1,9 +1,10 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { activeAtToken, + DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES, formatFileMention, WorkspaceFileSearch, } from '../src/search.ts' @@ -150,27 +151,78 @@ describe('WorkspaceFileSearch', () => { ]) }) - it('invalidates cached traversal, enforces the entry cap, and settles disposal', async () => { + it('serves an invalidated index while its replacement builds, then swaps it in', async () => { const root = await workspace() - const capped = search(root, { maxEntries: 2 }) - const signal = new AbortController().signal - expect(await capped.list('README', signal)).toEqual([ - { path: 'README.md', kind: 'file' }, - ]) - const files = search(root) + const signal = new AbortController().signal expect(await files.list('fresh-file', signal)).toEqual([]) await writeFile(join(root, 'fresh-file.ts'), 'fresh') + // No invalidation: the settled traversal is still the answer. expect(await files.list('fresh-file', signal)).toEqual([]) files.invalidate() - expect(await files.list('fresh-file', signal)).toEqual([ - { path: 'fresh-file.ts', kind: 'file' }, - ]) + // The stale entries answer this query; the rebuild runs behind the caret. + expect(await files.list('fresh-file', signal)).toEqual([]) + await vi.waitFor(async () => { + expect(await files.list('fresh-file', signal)).toEqual([ + { path: 'fresh-file.ts', kind: 'file' }, + ]) + }) files.dispose() expect(await files.list('fresh-file', signal)).toEqual([]) files.dispose() }) + it('keeps the stale entries when a refresh fails and retries on the next query', async () => { + const root = await workspace() + const files = search(root) + const signal = new AbortController().signal + expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + files.invalidate() + const scan = vi + .spyOn(files as unknown as { scanWorkspace: () => Promise }, 'scanWorkspace') + .mockRejectedValueOnce(new Error('scan failed')) + expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + await vi.waitFor(() => { expect(scan).toHaveBeenCalledTimes(1) }) + scan.mockRestore() + // The failed attempt left the index stale, so the next query starts a new one. + await writeFile(join(root, 'retried.ts'), 'retried') + expect(await files.list('retried', signal)).toEqual([]) + await vi.waitFor(async () => { + expect(await files.list('retried', signal)).toEqual([{ path: 'retried.ts', kind: 'file' }]) + }) + }) + + it('keeps nothing from a traversal that settles after disposal', async () => { + const root = await workspace() + const files = search(root) + const pending = files.list('README', new AbortController().signal) + files.dispose() + await expect(pending).rejects.toThrow('file search index disposed') + // The in-flight traversal still settles; its entries must reach no caller. + await vi.waitFor(async () => { + expect(await files.list('README', new AbortController().signal)).toEqual([]) + }) + }) + + it('enforces the entry cap', async () => { + const root = await workspace() + const capped = search(root, { maxEntries: 2 }) + expect(await capped.list('README', new AbortController().signal)).toEqual([ + { path: 'README.md', kind: 'file' }, + ]) + }) + + it('never traverses an excluded build output, so generated twins cannot outrank sources', async () => { + const root = await workspace() + await mkdir(join(root, 'lib'), { recursive: true }) + await writeFile(join(root, 'lib', 'terminal-view.js'), 'built') + const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] }) + expect(await files.list('terminal-view', new AbortController().signal)).toEqual([ + { path: 'src/terminal-view.ts', kind: 'file' }, + ]) + expect(await files.list('lib/', new AbortController().signal)).toEqual([]) + }) + it('cancels individual callers, skips missing directories, and validates limits', async () => { const root = await workspace() expect(() => search(root, { maxResults: 0 })).toThrow('maxResults') diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index b9e77e80bf..d4b4fd1711 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/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/context/session-reference/README.md -README.md: d900d4711ae7c2755153313503062cac14c37406 -README.zh.md: c9cac086f20194d2dbaa0fbe7eadd970560a248c +README.md: f88e1aacd7f6c83e25f90165f49dae183d0a94d5 +README.zh.md: c432abc8a551c3d9982c620d7c16d9d58e92611e diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index d900d4711a..f88e1aacd7 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -37,7 +37,7 @@ A message that cites other sessions is followed immediately by a `## Referenced ### Finding sessions to reference -`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the latest log-backed title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. +`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the latest log-backed title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. ### Configuration @@ -120,7 +120,8 @@ The request and snapshot are consecutive append-only target messages and preserv These limits define when cross-session references are a poor fit. They are current package constraints. -- **No body discovery** — candidate queries inspect folded titles but do not search message bodies. A non-empty query may inspect every visible persisted session log through the session-query service's bounded, cancellable batch; a dedicated title index may replace that discovery path without changing URI, snapshot, or persistence contracts. +- **No body discovery** — candidate queries inspect titles but do not search message bodies. +- **Discovery cost tracks projection-cache coverage** — a session the projection cache has checkpointed costs no log read at all. Every other session is folded from its log once and remembered while that log stays cold, so a first query over a corpus the cache has not covered still reads those logs; a dedicated title index may replace that path without changing URI, snapshot, or persistence contracts. Without the cache composed, an unfiltered listing folds only its cwd-ranked head, so its tail cannot match a title substring. - **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool. - **Text projection only** — non-text user and assistant blocks are not propagated across sessions. - **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations. diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index c9cac086f2..c432abc8a5 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" ### 查找可引用的会话 -`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或最新日志标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 +`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或最新日志标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 ### 配置 @@ -120,7 +120,8 @@ kind: "package-reference" 这些限制说明跨会话引用何时不合适。它们是当前包约束。 -- **不支持消息正文检索**:候选查询会检查折叠后的标题,但不搜索消息主体。非空查询可能通过 session-query 服务有界、可取消的批处理检查每个可见的持久化会话日志;专用标题索引未来可以替换这条发现路径,而不改变 URI、快照或持久化约定。 +- **不支持消息正文检索**:候选查询会检查标题,但不搜索消息主体。 +- **发现成本取决于投影缓存的覆盖率**:投影缓存已建立 checkpoint 的会话完全不需要读日志。其余会话各自从日志折叠一次,并在该日志保持冷态期间被记住;因此对缓存尚未覆盖的语料,首次查询仍会读取那些日志——专用标题索引未来可以替换这条路径,而不改变 URI、快照或持久化约定。未组合缓存时,未经过滤的列表只折叠按 cwd 排序的头部,其尾部因此无法命中标题子串。 - **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。 - **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。 - **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。 diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 7336a6a613..e28859eea6 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -59,10 +59,18 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-projection-cache": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", @@ -70,7 +78,10 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index c2b1adf3ea..861d93f086 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -11,8 +11,15 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' -import type { SessionSurfaceSnapshot, SessionTitleObservationResult } from '@deepseek-ai/dsh-session-query' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +// Type-only: the `title` projection key and the cache's Context merge, so +// discovery can label a cold session without reading its log. +import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection-cache' +import type {} from '@deepseek-ai/dsh-session-title' +import type { + SessionRecord, SessionSurfaceSnapshot, SessionTitleObservationResult, +} from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, DEFAULT_MAX_REFERENCE_BYTES, @@ -71,6 +78,25 @@ interface RenderedSource { stats: ReferenceRetentionStats } +/** One listed session, its listing position (the stable rank tiebreak), and its resolved title. */ +interface LabelledSession { + record: SessionRecord + index: number + label: string + /** + * No checkpoint answered for this session, so `label` is the id placeholder + * and only a log fold can improve it. + */ + unresolved?: boolean +} + +/** One folded cold title, pinned to the log identity that produced it. */ +interface FoldedTitle { + /** Creation facts of the folded header: a reused id with different ones is a different log. */ + identity: string + title: string | undefined +} + /** Exact-read consumer that prepares immutable cross-session message context. */ export class SessionReferenceResolver extends TypertRemoteService { static inject = ['sessionQuery'] @@ -81,6 +107,12 @@ export class SessionReferenceResolver extends TypertRemoteService { }) private readonly config: Required + /** + * Cold-log title folds, kept for the process lifetime. A log that no + * session is attached to never grows, so one fold answers every later + * keystroke instead of re-reading the log per query. + */ + private readonly foldedTitles = new Map() constructor(ctx: Context, config: Config = {}) { super(ctx, 'sessionReferenceResolver') @@ -149,6 +181,12 @@ export class SessionReferenceResolver extends TypertRemoteService { /** * List reference candidates, ranked by working-directory affinity. + * + * A title comes from the projection cache when that cache holds a + * checkpoint for the session; otherwise it is folded from the session's log + * once and remembered for as long as the log stays cold. Without the cache + * composed, only the cwd-ranked head of an unfiltered listing is folded, so + * its tail cannot match a title substring. * @param agent - target agent; self is excluded and its cwd drives ranking. * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. @@ -170,8 +208,67 @@ export class SessionReferenceResolver extends TypertRemoteService { const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal)) .filter(record => record.header.id !== agent.id) .map((record, index) => ({ record, index })) + const labelled = await this.labelCandidates(records, needle, limit, targetCwd, signal) + const page = labelled.filter(({ record, label }) => { + if (needle === '') return true + return record.header.id.toLocaleLowerCase().includes(needle) + || record.header.cwd?.toLocaleLowerCase().includes(needle) === true + || label.toLocaleLowerCase().includes(needle) + }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) + || a.index - b.index) + .slice(0, limit) + return (await this.foldUnresolved(page, signal)) + .map(({ record, label }) => ({ + sessionId: record.header.id, + label, + ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd }, + sameWorkspace: record.header.cwd !== undefined && record.header.cwd === targetCwd, + createdAt: record.header.createdAt, + })) + } + + /** + * Resolve the title of every candidate the caller may filter. + * + * With the projection cache composed it is the discovery index: titles come + * from its synchronous checkpoint rows, so a query filters the whole corpus + * without reading one log. Sessions the cache never checkpointed stay + * `unresolved` for {@link SessionReferenceResolver.resolvePageLabels} to + * fold. Without the cache, folding a title costs a full log read per + * session, so only the cwd-ranked head is inspected and an unlabeled tail + * cannot match a title substring. + * @param records - non-self session records in listing order. + * @param needle - lowercased query; empty means no title can change the result set. + * @param limit - caller result cap, applied here to the fold path's head. + * @param targetCwd - requesting agent's working directory (the ranking key). + * @param signal - caller cancellation. + * @returns labeled records for the caller to filter, rank, and cap. + */ + private async labelCandidates( + records: readonly { record: SessionRecord; index: number }[], + needle: string, + limit: number, + targetCwd: string | undefined, + signal: AbortSignal | undefined, + ): Promise { + const cache = this.ctx.get('sessionProjectionCache') + if (cache !== undefined) { + const labelled = records.map(({ record, index }) => { + const checkpoint = cachedTitle(cache.cachedSnapshot(record.header, ['title'])) + return { + record, + index, + label: checkpoint.title ?? record.header.id, + ...checkpoint.checkpointed ? {} : { unresolved: true }, + } + }) + // A filter reads every label, so a query cannot defer the sessions the + // cache never checkpointed to the page: fold them now. An empty query + // filters nothing, so its unresolved tail waits for the capped page. + return needle === '' ? labelled : this.foldUnresolved(labelled, signal) + } const inspected = needle === '' - ? records + ? [...records] .sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) || a.index - b.index) .slice(0, limit) @@ -189,20 +286,57 @@ export class SessionReferenceResolver extends TypertRemoteService { ? observation.value.title?.title ?? record.header.id : record.header.id, } - }).filter(({ record, label }) => { - if (needle === '') return true - return record.header.id.toLocaleLowerCase().includes(needle) - || record.header.cwd?.toLocaleLowerCase().includes(needle) === true - || label.toLocaleLowerCase().includes(needle) - }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) - || a.index - b.index) - .slice(0, limit) - .map(({ record, label }) => ({ - sessionId: record.header.id, - label, - ...record.header.cwd === undefined ? {} : { cwd: record.header.cwd }, - createdAt: record.header.createdAt, - })) + }) + } + + /** + * Apply every title the projection cache could not answer. + * + * A session persisted before the cache existed, seeded straight to disk, or + * whose record was cleared carries a title only in its log, and reading one + * costs a whole log. Memoized folds carry the cost once per cold log rather + * than once per keystroke; a session that is attached again is never + * memoized, because its log is still being appended to. + * @param entries - labeled rows, some still carrying the id placeholder. + * @param signal - caller cancellation. + * @returns the same rows with every foldable title applied. + */ + private async foldUnresolved( + entries: readonly LabelledSession[], + signal: AbortSignal | undefined, + ): Promise { + const live = this.ctx.get('sessions') + const pending: SessionHeader[] = [] + const resolved = new Map() + for (const { record, unresolved } of entries) { + if (unresolved !== true) continue + const memo = record.live ? undefined : this.foldedTitles.get(record.header.id) + if (memo !== undefined && memo.identity === foldIdentity(record.header)) { + if (memo.title !== undefined) resolved.set(record.header.id, memo.title) + continue + } + pending.push(record.header) + } + if (pending.length > 0) { + const observations = await settleWithCancellation( + this.ctx.sessionQuery.readTitleSnapshots(pending.map(header => header.id), signal), + signal, + ) + pending.forEach((header, at) => { + const observation = observations[at] as SessionTitleObservationResult + if (observation.status !== 'fulfilled') return + const title = observation.value.title?.title + if (title !== undefined) resolved.set(header.id, title) + // An attached session's log is still growing, so its fold is a cut, + // not a fact to keep. + if (live?.get(header.id) === undefined) { + this.foldedTitles.set(header.id, { identity: foldIdentity(header), title }) + } + }) + } + return entries.map(entry => (entry.unresolved === true + ? { ...entry, label: resolved.get(entry.record.header.id) ?? entry.label } + : entry)) } /** @@ -336,6 +470,20 @@ function renderPrompt(data: readonly ReferencedSessionData[]): string { return `${PROMPT_PREFIX}${stringifyTagSafeJson(data)}${PROMPT_SUFFIX}` } +/** The creation facts that make a header's log the same log the memo folded. */ +function foldIdentity(header: SessionHeader): string { + return `${String(header.createdAt)}:${header.cwd ?? ''}` +} + +/** Read one cached title row, separating "no checkpoint" from "checkpointed, still untitled". */ +function cachedTitle( + snapshot: ProjectionSnapshot | undefined, +): { checkpointed: boolean; title?: string } { + const title = snapshot?.values.title + if (title === undefined) return { checkpointed: false } + return title === null ? { checkpointed: true } : { checkpointed: true, title } +} + function candidateRank(candidateCwd: string | undefined, targetCwd: string | undefined): number { if (candidateCwd !== undefined && targetCwd !== undefined && candidateCwd === targetCwd) return 0 if (candidateCwd === undefined) return 1 diff --git a/packages/context/session-reference/src/types.ts b/packages/context/session-reference/src/types.ts index 6d908f34b9..87c11cd4c2 100644 --- a/packages/context/session-reference/src/types.ts +++ b/packages/context/session-reference/src/types.ts @@ -51,6 +51,12 @@ export interface SessionReferenceCandidate { label: string /** Source session working directory, when recorded. */ cwd?: string + /** + * True when {@link SessionReferenceCandidate.cwd} is recorded and equals the + * requesting agent's. Hosts that only surface a distinguishing location + * read this instead of comparing paths they never received. + */ + sameWorkspace: boolean /** Source session creation time in Unix epoch milliseconds. */ createdAt: number } diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index ab0211ce52..202c04bf17 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -40,6 +40,19 @@ async function harness(config: Config = {}): Promise { return ctx } +/** + * Stand in for the projection cache with a fixed checkpoint table: the + * resolver reads `cachedSnapshot` alone, and the point under test is which + * sessions still reach a log fold. + */ +function withProjectionCache(ctx: Context, rows: Record): void { + ctx.provide('sessionProjectionCache', { + cachedSnapshot: (meta: { id: SessionId }) => ( + meta.id in rows ? { asOfSeq: 0, values: { title: rows[meta.id] } } : undefined + ), + }) +} + function fakeAgent(session: Session): Agent { return { id: session.id, session } as Agent } @@ -253,16 +266,16 @@ describe('session reference discovery and preparation', () => { }) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 }, - { sessionId: SessionId('same'), label: 'same', cwd: '/same', createdAt: 20 }, - { sessionId: SessionId('none'), label: 'none', createdAt: 30 }, - { sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 }, + { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, + { sessionId: SessionId('same'), label: 'same', cwd: '/same', sameWorkspace: true, createdAt: 20 }, + { sessionId: SessionId('none'), label: 'none', sameWorkspace: false, createdAt: 30 }, + { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'els', 1)).resolves.toEqual([ - { sessionId: SessionId('other'), label: 'other', cwd: '/else', createdAt: 40 }, + { sessionId: SessionId('other'), label: 'other', cwd: '/else', sameWorkspace: false, createdAt: 40 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'LATEST', 1)).resolves.toEqual([ - { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', createdAt: 25 }, + { sessionId: SessionId('same-later'), label: 'Latest title', cwd: '/same', sameWorkspace: true, createdAt: 25 }, ]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), '', 0)) .rejects.toThrow(expectCode('SESSION_REFERENCE_INVALID_REFERENCE')) @@ -283,6 +296,143 @@ describe('session reference discovery and preparation', () => { listSessions.mockRestore() }) + it('labels and filters the whole corpus from checkpoints, reading no log', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + for (const id of ['alpha', 'beta']) { + const created = ctx.sessions.create(SessionId(id), { meta: { cwd: '/same' } }) + created.append('session/title', { title: `${id} title`, messageSeqs: [], source: { kind: 'fallback' } }) + } + withProjectionCache(ctx, { alpha: 'Alpha checkpoint', beta: 'Beta checkpoint' }) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'alpha check')) + .resolves.toEqual([ + { sessionId: SessionId('alpha'), label: 'Alpha checkpoint', cwd: '/same', sameWorkspace: true, createdAt: expect.any(Number) as number }, + ]) + expect(readTitles).not.toHaveBeenCalled() + readTitles.mockRestore() + }) + + it('folds a title the cache never checkpointed, for the shown page alone', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const seeded = ctx.sessions.create(SessionId('seeded'), { meta: { cwd: '/same' } }) + seeded.append('session/title', { title: 'Seeded title', messageSeqs: [], source: { kind: 'fallback' } }) + const untitled = ctx.sessions.create(SessionId('untitled'), { meta: { cwd: '/same' } }) + // `untitled` is checkpointed with no title yet: nothing a log fold could add. + withProjectionCache(ctx, { untitled: null }) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ + { sessionId: seeded.id, label: 'Seeded title', cwd: '/same', sameWorkspace: true, createdAt: seeded.header.createdAt }, + { sessionId: untitled.id, label: untitled.id, cwd: '/same', sameWorkspace: true, createdAt: untitled.header.createdAt }, + ]) + // Only the uncheckpointed session reached a log. + expect(readTitles).toHaveBeenCalledTimes(1) + expect(readTitles.mock.calls[0]?.[0]).toEqual([seeded.id]) + readTitles.mockRestore() + }) + + it('folds the uncheckpointed tail so a query still filters on its titles', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const seeded = ctx.sessions.create(SessionId('seeded'), { meta: { cwd: '/same' } }) + seeded.append('session/title', { title: 'Research notes', messageSeqs: [], source: { kind: 'fallback' } }) + withProjectionCache(ctx, {}) + + // The title lives only in the log, and the filter reads labels — so a + // deferred fold would make this session unfindable by its own title. + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'research')) + .resolves.toEqual([ + { sessionId: seeded.id, label: 'Research notes', cwd: '/same', sameWorkspace: true, createdAt: seeded.header.createdAt }, + ]) + }) + + it('folds a cold log once and answers every later query from that fold', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const cold = { id: SessionId('cold'), createdAt: 10, cwd: '/same' } + withProjectionCache(ctx, {}) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ + { header: { ...target.header }, live: true, persisted: false }, + { header: cold, live: false, persisted: true }, + ] as never) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValue([{ + sessionId: cold.id, + status: 'fulfilled', + value: { session: cold, title: { title: 'Cold title' } }, + }] as never) + + const expected = [{ sessionId: cold.id, label: 'Cold title', cwd: '/same', sameWorkspace: true, createdAt: 10 }] + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'cold')).resolves.toEqual(expected) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'cold t')).resolves.toEqual(expected) + // A cold log never grows, so the second keystroke reads nothing. + expect(readTitles).toHaveBeenCalledTimes(1) + vi.restoreAllMocks() + }) + + it('remembers that a cold log has no title, and stops reading it', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + withProjectionCache(ctx, {}) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ + { header: { id: SessionId('bare'), createdAt: 10 }, live: false, persisted: true }, + ] as never) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValue([{ + sessionId: SessionId('bare'), + status: 'fulfilled', + value: { session: {} }, + }] as never) + + const expected = [{ sessionId: SessionId('bare'), label: 'bare', sameWorkspace: false, createdAt: 10 }] + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'bare')).resolves.toEqual(expected) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'bar')).resolves.toEqual(expected) + expect(readTitles).toHaveBeenCalledTimes(1) + vi.restoreAllMocks() + }) + + it('refolds a cold id whose log was replaced under it', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + withProjectionCache(ctx, {}) + let createdAt = 10 + vi.spyOn(ctx.sessionQuery, 'listSessions').mockImplementation(() => Promise.resolve([ + { header: { id: SessionId('cold'), createdAt, cwd: '/same' }, live: false, persisted: true }, + ] as never)) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') + .mockImplementation(() => Promise.resolve([{ + sessionId: SessionId('cold'), + status: 'fulfilled', + value: { session: {}, title: { title: `Title at ${String(createdAt)}` } }, + }] as never)) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'title')) + .resolves.toMatchObject([{ label: 'Title at 10' }]) + createdAt = 20 + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'title')) + .resolves.toMatchObject([{ label: 'Title at 20' }]) + expect(readTitles).toHaveBeenCalledTimes(2) + vi.restoreAllMocks() + }) + + it('leaves the id placeholder when the page fold cannot read the log', async () => { + const ctx = await harness() + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const broken = ctx.sessions.create(SessionId('broken'), { meta: { cwd: '/same' } }) + withProjectionCache(ctx, {}) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{ + sessionId: broken.id, + status: 'rejected', + reason: new Error('broken title log'), + }]) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ + { sessionId: broken.id, label: broken.id, cwd: '/same', sameWorkspace: true, createdAt: broken.header.createdAt }, + ]) + readTitles.mockRestore() + }) + it('serves the Remote face with the configured limit and canonical mentions', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same', createdAt: 10 } }) @@ -296,6 +446,7 @@ describe('session reference discovery and preparation', () => { sessionId: SessionId('source]'), label: 'source]', cwd: '/same', + sameWorkspace: true, createdAt: 20, mention: formatSessionReferenceMention({ sessionId: SessionId('source]'), label: 'source]' }), }]) @@ -389,7 +540,7 @@ describe('session reference discovery and preparation', () => { }]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([ - { sessionId: source.id, label: source.id, createdAt: source.header.createdAt }, + { sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt }, ]) let releaseTitles: (() => void) | undefined diff --git a/packages/context/session-reference/tsconfig.json b/packages/context/session-reference/tsconfig.json index be04faf516..47d2f50e91 100644 --- a/packages/context/session-reference/tsconfig.json +++ b/packages/context/session-reference/tsconfig.json @@ -4,17 +4,48 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ - { "path": "../../../vendor/cosmokit" }, - { "path": "../../../vendor/cordis" }, - { "path": "../../../vendor/schemastery" }, - { "path": "../../util/output-retention" }, - { "path": "../../llm/llm" }, - { "path": "../../core/session" }, - { "path": "../../core/agent" }, - { "path": "../../compaction/compaction" }, - { "path": "../../runtime-diagnostics/invariants" }, - { "path": "../../session-query/session-query" } + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/output-retention" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../compaction/compaction" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../session-query/session-query" + }, + { + "path": "../../session/session-projection" + }, + { + "path": "../../session/session-projection-cache" + }, + { + "path": "../../session/session-title" + } ] } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5412e025cd..5a0178c36c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1708,7 +1708,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise', - description: 'List reference candidates, ranked by working-directory affinity.', + description: 'List reference candidates, ranked by working-directory affinity.\n\nA title comes from the projection cache when that cache holds a checkpoint for the session; otherwise it is folded from the session\'s log once and remembered for as long as the log stays cold. Without the cache composed, only the cwd-ranked head of an unfiltered listing is folded, so its tail cannot match a title substring.', parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], returns: 'candidates labeled by latest title or, when absent, session id.', }, @@ -4876,7 +4876,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionReferenceCandidate', - declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n cwd?: string;\n createdAt: number;\n}', + declaration: 'export interface SessionReferenceCandidate {\n sessionId: SessionId;\n label: string;\n cwd?: string;\n sameWorkspace: boolean;\n createdAt: number;\n}', }, { name: 'SessionReferenceInput', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f271832de3..90aa404403 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2850,12 +2850,18 @@ importers: '@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-locale': specifier: workspace:^ version: link:../locale '@deepseek-ai/dsh-client-ui-input-trigger': specifier: workspace:^ version: link:../ui-input-trigger + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots @@ -2871,6 +2877,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path packages/client/ui-renderer: dependencies: @@ -4052,9 +4061,18 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session/session-projection + '@deepseek-ai/dsh-session-projection-cache': + specifier: workspace:^ + version: link:../../session/session-projection-cache '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session/session-title '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol From c8e8f8249f1e1ca743d6b4f02579969595b2b76e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 11:50:52 +0800 Subject: [PATCH 029/130] fix(web): date @ session rows by last activity, not creation The Host session list already carries each session's `updatedAt`, which is the number its own rows show; reading it there keeps the two surfaces from disagreeing and avoids making a context capability depend on the BFF assembly that owns the `sessionListMetadata` projection. A session the list does not carry falls back to the candidate's creation time. Refs #3154 --- ...ention-discovery-and-row-content.i18n.yaml | 4 +-- ...eb-at-mention-discovery-and-row-content.md | 4 ++- ...at-mention-discovery-and-row-content.zh.md | 4 ++- docs/module-graph.i18n.yaml | 4 +-- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/client/ui-reference/README.i18n.yaml | 4 +-- packages/client/ui-reference/README.md | 2 +- packages/client/ui-reference/README.zh.md | 2 +- packages/client/ui-reference/package.json | 3 ++ .../client/ui-reference/src/client/index.ts | 25 ++++++++++---- .../tests/browser-plugin.client.spec.ts | 33 ++++++++++++++++--- packages/client/ui-reference/tsconfig.json | 3 ++ pnpm-lock.yaml | 3 ++ 14 files changed, 74 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml index b8cfc26916..8b48c9526c 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.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-27-web-at-mention-discovery-and-row-content.md -2026-08-27-web-at-mention-discovery-and-row-content.md: bb2bdfd19a453262d6b2311e17412e4e8761606e -2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8f45da994973c21f42f6cba1e8c8402238719260 +2026-08-27-web-at-mention-discovery-and-row-content.md: 49587a35411952ddb270e2dc2687dcc98b3bebe5 +2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 0a26f5ddc2cb1469c2b69c160a134e36cd84586e diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md index bb2bdfd19a..49587a3541 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md @@ -24,7 +24,7 @@ The cache is optional, and without it the previous fold path stands unchanged, i **An invalidated file index keeps answering while its replacement builds.** `invalidate()` bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A failed refresh leaves the stale entries and the counter behind, so the next query retries. `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` grows from two names to sixteen — version-control and dependency stores plus the build-output basenames of the ecosystems this harness runs in — and `DEFAULT_FILE_SEARCH_MAX_ENTRIES` rises to 50 000. Both remain `excludedDirectories` and `maxEntries` config fields a deployment overrides. -**Rows carry only what distinguishes them.** A file names its parent directory and nothing at the workspace root. A drilled directory listing names no parent, because its breadcrumb does. A session names its workspace only when `SessionReferenceCandidate.sameWorkspace` is false — the host computes that, since it already holds both working directories for ranking — and is dated with the relative-time bucket the session list uses, so one session reads the same age on both surfaces. `relativeTime` moves from `ui-workspace`'s `tree.ts` to `ui-primitives`; the words stay in each plugin's own dictionary, per locale-owned copy. The session id leaves the row: it is already the label a session without a title falls back to. +**Rows carry only what distinguishes them.** A file names its parent directory and nothing at the workspace root. A drilled directory listing names no parent, because its breadcrumb does. A session names its workspace only when `SessionReferenceCandidate.sameWorkspace` is false — the host computes that, since it already holds both working directories for ranking — and is dated from the Host session list's `updatedAt` through the relative-time bucket that list uses, so one session reads the same age on both surfaces. A session the list does not carry falls back to the candidate's `createdAt`. `relativeTime` moves from `ui-workspace`'s `tree.ts` to `ui-primitives`; the words stay in each plugin's own dictionary, per locale-owned copy. The session id leaves the row: it is already the label a session without a title falls back to. **A drill publishes a breadcrumb; typing a path does not.** `InputTriggerSource` gains an optional synchronous `header(session, req)` hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned `drilled` flag that says whether a drill or typing produced it. `CandidateRequest` carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through `onPick` with `action: 'drill'` — returning to a step and descending into one are one outcome. `MenuView` renders the header above its scrolling viewport and moves `role="listbox"` onto that viewport, because a breadcrumb is not an option and a listbox may not carry one. @@ -40,6 +40,8 @@ The zh composer placeholder says `文件或对话`, matching the `对话` sectio **Read `.gitignore` to bound the index.** Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list covers the measured 41% and stays a config field. A workspace that keeps sources under one of those basenames must override `excludedDirectories`. +**Read the session's last activity on the host, from the `sessionListMetadata` projection.** Rejected: that projection key is declared by `api-session-controller`, so reading it would make a `packages/context` capability depend on the BFF assembly — a direction with no precedent in this repository. The client already holds the same number in `ctx.sessions.list`, which is also what makes the two surfaces agree by construction rather than by coincidence. + **Let `MenuView` recognize the `@` trigger and draw the breadcrumb itself.** Rejected: `MenuView` is shared with `/`, and hardcoding file-reference semantics there crosses the package boundary the source registry exists to hold. **Add a `drilled` flag to `CandidateRequest` as optional.** Rejected: the pipeline always knows it, and an optional field invites a source to read `undefined` as "not drilled" for a request that simply predates the field. Required, with every call site updated, matches the pre-release stance. diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md index 8f45da9949..0a26f5ddc2 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md @@ -24,7 +24,7 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 **失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十六个——版本控制与依赖目录,加上本 harness 运行的各生态的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories` 与 `maxEntries` 配置字段。 -**每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算,因为排序时它本就同时握有两个工作目录——并使用会话列表所用的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。`relativeTime` 从 `ui-workspace` 的 `tree.ts` 移到 `ui-primitives`;按 locale-owned 文案的规则,词句仍留在各插件自己的字典里。session id 离开行内:它本就是无标题会话回落到的标签。 +**每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算,因为排序时它本就同时握有两个工作目录——并用宿主会话列表的 `updatedAt` 经该列表所用的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。列表中没有的会话回落到候选自带的 `createdAt`。`relativeTime` 从 `ui-workspace` 的 `tree.ts` 移到 `ui-primitives`;按 locale-owned 文案的规则,词句仍留在各插件自己的字典里。session id 离开行内:它本就是无标题会话回落到的标签。 **下钻会发布面包屑,键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑,在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问,后者说明该查询由下钻还是键入产生。`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store,冻结的菜单归约器因此对它一无所知;点击面包屑经 `onPick` 以 `action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上,并把 `role="listbox"` 移到该视口上,因为面包屑不是选项,listbox 也不得承载它。 @@ -40,6 +40,8 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 **读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表覆盖了实测的 41%,且本就是配置字段。把源码放在其中某个基名下的工作区需覆盖 `excludedDirectories`。 +**在宿主侧从 `sessionListMetadata` 投影读取会话最近活动时间。** 否决:该投影键由 `api-session-controller` 声明,读取它会让 `packages/context` 的能力依赖 BFF 装配层——本仓库没有这个方向的先例。客户端的 `ctx.sessions.list` 里本就有同一个数字,而这也正是让两处界面「由构造而非由巧合」保持一致的原因。 + **让 `MenuView` 识别 `@` 触发符并自行绘制面包屑。** 否决:`MenuView` 与 `/` 共用,把文件引用语义硬编码进去,越过了 source 注册表本就用来守住的包边界。 **把 `drilled` 作为可选字段加进 `CandidateRequest`。** 否决:管线始终知道它,而可选字段会诱使 source 把「请求早于该字段」读成「未下钻」。改为必填并更新每一处调用点,符合预发布阶段的取舍。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 932611b278..2926dcc1c0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 902a0e51e9fa0af603eb0a0588d19110d31c84c4 -module-graph.zh.md: 5a71e69a7f6bf8c9d4959afda06f9afe8cf3868e +module-graph.md: 41b05c95f7e45c2fc044ce7bf62dfc8c7d31c45e +module-graph.zh.md: 1fb86d8745ce909d97ada49565bf200c0eb868b2 diff --git a/docs/module-graph.md b/docs/module-graph.md index 902a0e51e9..41b05c95f7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1546,6 +1546,7 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_api_session_controller pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger @@ -1926,7 +1927,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 5a71e69a7f..1fb86d8745 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1548,6 +1548,7 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_api_session_controller pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger @@ -1928,7 +1929,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/packages/client/ui-reference/README.i18n.yaml b/packages/client/ui-reference/README.i18n.yaml index 6c3841170b..fae26277f7 100644 --- a/packages/client/ui-reference/README.i18n.yaml +++ b/packages/client/ui-reference/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-reference/README.md -README.md: 9c2655fcac96803ba47864c2ab99ed123ca8e201 -README.zh.md: 15f61ba1a692539e87bfe551225b346b78a203e7 +README.md: 1f82f4bb30f2ad5d194a70279f9e80885d6a048e +README.zh.md: 03a79ec4ff34431a63d8a7b0de115e3ec63b9392 diff --git a/packages/client/ui-reference/README.md b/packages/client/ui-reference/README.md index 9c2655fcac..1f82f4bb30 100644 --- a/packages/client/ui-reference/README.md +++ b/packages/client/ui-reference/README.md @@ -49,7 +49,7 @@ The source keeps candidate encoding internal to the registration effect: the `/c ### Candidate flow -For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated with the same relative-time bucket the session list uses, so one session reads the same age on both surfaces. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. +For an unquoted token, the browser starts the `fileReferences/list` and `sessionReferenceResolver/candidates` Remote calls together, then deterministically orders files before sessions with locale-registered folder/file/session labels. Rows render under non-selectable file and session section headings without a redundant raw `reference` source title. A session row is dated from the Host session list's `updatedAt` through the same relative-time bucket that list uses, so one session reads the same age on both surfaces; a session the list does not carry falls back to the candidate's creation time. A drilled query publishes a breadcrumb from the workspace root to the directory being listed; each crumb carries the drill payload a folder row would, so returning to a step and descending into one are one outcome. ### Serialization diff --git a/packages/client/ui-reference/README.zh.md b/packages/client/ui-reference/README.zh.md index 15f61ba1a6..03a79ec4ff 100644 --- a/packages/client/ui-reference/README.zh.md +++ b/packages/client/ui-reference/README.zh.md @@ -49,7 +49,7 @@ kind: "package-reference" ### 候选流程 -对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行使用与会话列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 +对于未加引号的 token,浏览器会同时启动 `fileReferences/list` 与 `sessionReferenceResolver/candidates` Remote 调用,再以确定性顺序把文件排在会话之前,并使用注册在 locale 字典中的文件夹、文件与会话标签。各行分别渲染在不可选择的文件与会话分组标题下,不显示重复的原始 `reference` source 标题。会话行用宿主会话列表的 `updatedAt` 经该列表相同的相对时间分档标注时间,因此同一个会话在两处读到的时长一致;列表中没有的会话回落到候选自带的创建时间。下钻后的查询会发布一条从工作区根目录到当前所列目录的面包屑;每一节携带的下钻载荷与文件夹行相同,因此「回到某一步」与「进入某一层」是同一个结果。 ### 序列化 diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index aa1ce68bd3..f7e9afbde5 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -33,6 +33,7 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-input-trigger" @@ -47,6 +48,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", @@ -59,6 +61,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index cafe481c56..c82adcdbe4 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -6,7 +6,8 @@ * Rows carry only what distinguishes them: a file names its parent directory * (nothing at the workspace root), a directory listing names none because its * breadcrumb already does, and a session names its workspace only when that - * workspace is not the current one. + * workspace is not the current one. A session is dated from the Host session + * list, so the `@` menu and the session list never disagree about its age. * * @module @deepseek-ai/dsh-client-ui-reference/client */ @@ -15,6 +16,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives' import type { @@ -28,7 +30,7 @@ import { en, NS, zh, type ReferenceKey } from './locales.ts' /** Required services: the trigger registry, the Remote namespaces, and the copy. */ export const inject = [ - 'inputTriggers', 'locale', 'connection', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ] @@ -40,31 +42,39 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-reference: dictionaries') const t = ctx.locale.bind(NS) const connection = ctx.get('connection') as ConnectionHandle + const sessions = ctx.get('sessions') as ISessions const source: InputTriggerSource = { trigger: '@', name: 'reference', showGroupTitle: false, async candidates(session: ClientSessionContext, { query, quoted, drilled, signal }) { - const files = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( + const fileLookup = ctx.remote.fileReferences.list(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) - const sessions = quoted === true + const sessionLookup = quoted === true ? Promise.resolve([] as SessionReferenceMentionCandidate[]) : ctx.remote.sessionReferenceResolver.candidates(session.sessionId, query, signal).then( result => result.ok ? result.value : [], () => [], ) - const [fileItems, sessionItems] = await Promise.all([files, sessions]) + const [fileItems, sessionItems] = await Promise.all([fileLookup, sessionLookup]) if (signal.aborted) return [] // The header already names the directory being listed; rows repeat it only // when there is no header to carry it. const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined const now = Date.now() const home = connection.hostDescription.getSnapshot()?.home + const listed = sessions.list.getSnapshot().byId return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), - ...sessionItems.map(candidate => sessionCandidate(candidate, now, home, t)), + ...sessionItems.map(candidate => sessionCandidate( + candidate, + listed[candidate.sessionId]?.updatedAt ?? candidate.createdAt, + now, + home, + t, + )), ] }, header(_session: ClientSessionContext, req) { @@ -198,11 +208,12 @@ function fileCandidate( function sessionCandidate( candidate: SessionReferenceMentionCandidate, + updatedAt: number, now: number, home: string | undefined, t: Translate, ) { - const { unit, n } = relativeTime(candidate.createdAt, now) + const { unit, n } = relativeTime(updatedAt, now) const age = unit === 'now' ? t('time.now') : t(`time.${unit}`, { n }) // Candidates are ranked by workspace affinity, so the location only tells // the user something when it is not the workspace they are already in. diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 51907a75b7..03e19b1b27 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -22,6 +22,8 @@ const HOME = '/Users/dev' const CREATED_AT = 1_700_000_000_000 /** Three days after every fixture's createdAt, so age copy is one fixed bucket. */ const NOW = CREATED_AT + 3 * 86_400_000 +/** The Host session list dates a row; only a session missing from it falls back to createdAt. */ +const UPDATED_AT = NOW - 3_600_000 beforeEach(() => { vi.useFakeTimers({ toFake: ['Date'] }) @@ -71,6 +73,7 @@ async function bench( mention: '@[Research](dsh-session:InNvdXJjZSI)', }], })), + listed: Record = {}, ): Promise<{ ctx: Context; fiber: ReturnType; source: InputTriggerSource }> { const ctx = new Context() let source: InputTriggerSource | undefined @@ -90,6 +93,7 @@ async function bench( ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } }) + ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() if (source === undefined) throw new Error('reference source was not registered') @@ -99,7 +103,7 @@ async function bench( describe('apply', () => { it('declares its services and releases the @ reference registration on disposal', async () => { expect(inject).toEqual([ - 'inputTriggers', 'locale', 'connection', 'remote', 'remote.fileReferences', + 'inputTriggers', 'locale', 'connection', 'sessions', 'remote', 'remote.fileReferences', 'remote.sessionReferenceResolver', ]) const { fiber } = await bench() @@ -121,6 +125,7 @@ describe('apply', () => { ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } }) + ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() expect(registered).toMatchObject({ trigger: '@', name: 'reference', showGroupTitle: false }) @@ -177,7 +182,7 @@ describe('candidates', () => { }) } })) - const { source } = await bench(files, sessions) + const { source } = await bench(files, sessions, { source: { updatedAt: UPDATED_AT } }) const pending = source.candidates(session, request('re')) expect(files).toHaveBeenCalledTimes(1) expect(sessions).toHaveBeenCalledTimes(1) @@ -199,7 +204,7 @@ describe('candidates', () => { }), expect.objectContaining({ name: 'Research', - description: '~/project · 3d', + description: '~/project · 1h', icon: 'session', section: 'Sessions', }), @@ -299,6 +304,26 @@ describe('candidates', () => { ]) }) + it('falls back to the candidate createdAt for a session the Host list does not carry', async () => { + const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const sessions = vi.fn(() => Promise.resolve({ + ok: true as const, + value: [{ + sessionId: sid('unlisted'), + label: 'Unlisted run', + cwd: `${HOME}/project`, + sameWorkspace: true, + createdAt: CREATED_AT, + mention: '@[Unlisted run](dsh-session:InVubGlzdGVkIg)', + }], + })) + // A row absent from the list has no durable activity time to read. + const { source } = await bench(files, sessions, { other: { updatedAt: UPDATED_AT } }) + await expect(source.candidates(session, request('unlisted'))).resolves.toEqual([ + expect.objectContaining({ name: 'Unlisted run', description: '3d' }), + ]) + }) + it('reads a session opened moments ago as the present, not a zero distance', async () => { const files = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) const sessions = vi.fn(() => Promise.resolve({ @@ -312,7 +337,7 @@ describe('candidates', () => { mention: '@[Just now](dsh-session:Imp1c3Qtbm93Ig)', }], })) - const { source } = await bench(files, sessions) + const { source } = await bench(files, sessions, { 'just-now': { updatedAt: NOW - 1_000 } }) await expect(source.candidates(session, request('just'))).resolves.toEqual([ expect.objectContaining({ name: 'Just now', description: 'now' }), ]) diff --git a/packages/client/ui-reference/tsconfig.json b/packages/client/ui-reference/tsconfig.json index bc35a964c1..edaf356f28 100644 --- a/packages/client/ui-reference/tsconfig.json +++ b/packages/client/ui-reference/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../api/remotes/tsconfig.client.json" }, + { + "path": "../../api/session-controller/tsconfig.client.json" + }, { "path": "../../context/file-reference" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90aa404403..a18f145286 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2850,6 +2850,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-api-session-controller': + specifier: workspace:^ + version: link:../../api/session-controller '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection From f887a8f9076ea319af7d22644c7a103f8b83da2a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 20:39:58 +0800 Subject: [PATCH 030/130] fix(web): move subagent model switch to Plugins --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 2 +- ...08-18-model-selected-subagent-routes.zh.md | 2 +- .../models-settings/configured.expected.md | 4 - .../models-settings/declared-edit.expected.md | 4 - .../models-settings/declared.expected.md | 4 - .../models-settings/empty.expected.md | 4 - .../models.expected.md | 4 - .../dismissed.expected.md | 4 - .../plugin-config/section.expected.md | 4 + apps/web/tests/plugin-config.e2e.ts | 21 +++- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 4 +- docs/tool-catalog.zh.md | 4 +- packages/bundle/web-app/cordis.patch.yml | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 - .../client/ui-settings-models/README.zh.md | 4 - .../src/client/ModelsSection.module.css | 81 ------------- .../src/client/ModelsSection.tsx | 13 --- .../src/client/SubagentModelSelectionCard.tsx | 88 -------------- .../ui-settings-models/src/client/locales.ts | 8 -- .../tests/components.client.spec.tsx | 67 ----------- .../tests/store.client.spec.ts | 10 +- .../ui-settings-plugins/README.i18n.yaml | 4 +- packages/client/ui-settings-plugins/README.md | 8 +- .../client/ui-settings-plugins/README.zh.md | 8 +- .../SubagentModelSelectionCard.module.css | 87 ++++++++++++++ .../src/client/SubagentModelSelectionCard.tsx | 47 ++++++++ .../ui-settings-plugins/src/client/index.ts | 21 +++- .../ui-settings-plugins/src/client/locales.ts | 12 ++ ...ubagent-model-selection-card-controller.ts | 108 ++++++++++++++++++ .../tests/apply.client.spec.ts | 4 +- .../tests/section.client.spec.tsx | 57 +++++++++ .../tests/stores.client.spec.ts | 83 ++++++++++++++ .../src/client/slot-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 2 +- 37 files changed, 474 insertions(+), 318 deletions(-) delete mode 100644 packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx create mode 100644 packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css create mode 100644 packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx create mode 100644 packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 39403b4062..ee76abc897 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: bf4788b141370933197d9ec1a1ad3c8e76a6740c -2026-08-18-model-selected-subagent-routes.zh.md: 1d83e2e91ffe87fff7f8e9d1988320cb2bb8f2f7 +2026-08-18-model-selected-subagent-routes.md: c6e4ad70571d70b6a9a7d803b2b372e79599b184 +2026-08-18-model-selected-subagent-routes.zh.md: 6fac3f60e19ce824eb07ae2df83bdd05c7e75090 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index bf4788b141..c6e4ad7057 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -18,7 +18,7 @@ Provider and model form one route and must be supplied together. An effort may b An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. The Plugins settings page exposes that namespace as a direct switch. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 1d83e2e91f..6fac3f60e1 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -18,7 +18,7 @@ Status: implemented 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。插件设置页将该命名空间显示为直接开关。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 diff --git a/apps/web/tests/expected/models-settings/configured.expected.md b/apps/web/tests/expected/models-settings/configured.expected.md index 6ac6d76796..3c3be0922c 100644 --- a/apps/web/tests/expected/models-settings/configured.expected.md +++ b/apps/web/tests/expected/models-settings/configured.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - status: 已保存 minimax-cn。 - list: - listitem: diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 2f7d4a3a30..1d538bcfe4 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/declared.expected.md b/apps/web/tests/expected/models-settings/declared.expected.md index bb129bf2ea..df48328fd3 100644 --- a/apps/web/tests/expected/models-settings/declared.expected.md +++ b/apps/web/tests/expected/models-settings/declared.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: minimax-cn diff --git a/apps/web/tests/expected/models-settings/empty.expected.md b/apps/web/tests/expected/models-settings/empty.expected.md index dfeb637b5f..54bf1db3c3 100644 --- a/apps/web/tests/expected/models-settings/empty.expected.md +++ b/apps/web/tests/expected/models-settings/empty.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list - text: 提供方 - combobox "提供方": diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 020f70d095..a302932e65 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md index 73c66388f3..496443b057 100644 --- a/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md +++ b/apps/web/tests/expected/onboarding-usable-provider/dismissed.expected.md @@ -19,10 +19,6 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 - - region "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" - list: - listitem: - text: DeepSeek diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 54ac42b4a7..6c17e68db9 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -24,6 +24,10 @@ - tab "插件列表" - tabpanel "插件配置": - list: + - listitem "Subagent 自选模型": + - heading "Subagent 自选模型" [level=3] + - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 + - switch "允许 subagent 自选模型" - listitem: - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 0235f687de..ab8515d323 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -75,8 +75,10 @@ describe('web e2e: plugin configuration section', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) const dialog = await openPlugins() - // Every card the shipped web composition exposes: the shell executor, the - // agent loop, and the DeepSeek search provider. + // Every card the shipped web composition exposes: subagent selection, the + // shell executor, the agent loop, and the DeepSeek search provider. + await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('switch', { name: '允许 subagent 自选模型' }).getAttribute('aria-checked')).toBe('false') await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -88,6 +90,21 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('immediately persists the subagent model-selection preference', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) + const dialog = await openPlugins() + const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' }) + + await toggle.click() + + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') + await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('enabled: true') + expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('stages an edit and writes it only when saved', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-write')) const dialog = await openPlugins() diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 5b23decb3c..46e2eebdd3 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 7b166243fc3f5ef2c1bacdddaf5ee44c5b155622 -tool-catalog.zh.md: b44a0de4968dbcd760db546037f35e844608c819 +tool-catalog.md: be8f503ed983e68e39da1a9401ffbe70a029968c +tool-catalog.zh.md: 16fd7de1235250d91dfa7df2304d1a66ba192b0d diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 7b166243fc..be8f503ed9 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1601,7 +1601,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index b44a0de496..16fd7de123 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1607,7 +1607,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取 Models 页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 3f889dd8b4..d69e66753a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -42,7 +42,7 @@ # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: # Host-owned opt-in sampled when a new Web session receives its preset - # delegation tools. The Models page edits this settings namespace. + # delegation tools. The Plugins page edits this settings namespace. - id: subagent-model-selection-settings name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 35e7f7557a..9772bd7a42 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: 6ed2bde147f7c9d3853196fa023d7461f977b2da -README.zh.md: 3806244d4deec80a31d9c4bafc6a11aaddbb528f +README.md: 658b357992a1926f1f21e109c19b7c0975da58ea +README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6ed2bde147..658b357992 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -35,10 +35,6 @@ The primary field on an editor card is a single **API key** input — the page n The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately not among the editable fields: it is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits. -### Subagent model selection - -When the Host advertises the `subagent-model-selection` settings namespace, Models shows a localized switch above the provider rows. The switch defaults off and writes only `{ enabled }` through `settings.update` with the namespace revision. The Host samples the value when it composes a new top-level Session, so changing it does not reconfigure running Sessions. Child Sessions inherit their parent's recorded decision. - ### Adding and deleting providers The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 3806244d4d..dcc19f80a1 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -35,10 +35,6 @@ kind: "package-reference" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意不在可编辑字段之列:它是按模型的能力,提供方级的控件只可能被设成某些模型会拒绝的值。每个 DeepSeek 行编辑 `id`、可选显示 `name` 与可选 `contextWindow`/`maxTokens`;该精选集之外的现有字段在编辑后仍会保留。 -### 子代理模型选择 - -当宿主提供 `subagent-model-selection` 设置 namespace 时,Models 会在提供方行上方显示一个本地化开关。该开关默认关闭,并通过 `settings.update` 携带 namespace revision、只写入 `{ enabled }`。宿主在组合新的顶层 Session 时读取此值,因此更改它不会重新配置正在运行的 Session。子 Session 会继承其父级已记录的决定。 - ### 新增与删除提供方 「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 1c767386dd..fe2fe87d3a 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -40,87 +40,6 @@ color: var(--dsw-alias-state-success-primary); } -.preferenceCard { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - align-items: center; - gap: 8px 16px; - margin-top: 4px; - padding: 14px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; -} - -.preferenceCopy { - min-width: 0; -} - -.preferenceTitle { - margin: 0; - font-size: 14px; - line-height: 22px; - font-weight: 500; - color: var(--dsw-alias-label-primary); -} - -.preferenceDescription { - margin: 2px 0 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-label-tertiary); -} - -.switch { - box-sizing: border-box; - position: relative; - width: 36px; - height: 20px; - padding: 2px; - border: 0; - border-radius: 10px; - background: var(--dsw-alias-border-l3); - cursor: pointer; -} - -.switchOn { - background: var(--dsw-alias-brand-primary); -} - -.switch:disabled { - cursor: default; - opacity: 0.5; -} - -.switch:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--dsw-alias-border-l3); -} - -.switchThumb { - display: block; - width: 16px; - height: 16px; - border-radius: 50%; - background: var(--dsw-alias-label-primary-foreground); - transition: transform 120ms ease; -} - -.switchOn .switchThumb { - transform: translateX(16px); -} - -.preferenceStatus, -.preferenceCard > .error { - grid-column: 1 / -1; -} - -.preferenceStatus { - margin: 0; - font-size: 12px; - line-height: 18px; - color: var(--dsw-alias-state-success-primary); -} - .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b12fb9fc01..7b884ac906 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -23,7 +23,6 @@ import { deriveKeyRef, messageOf, protocolChoices, providerUsable } from './stor import type { ModelsSettingsStore, ModelsWire, ProviderRow } from './store.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' -import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -309,24 +308,12 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS // one whose schema names the protocols one may speak; without it mounted // there is nothing to declare and the entry point stays disabled. const protocols = protocolChoices(state.namespaces.get('llm-pi-ai'), schema) - const subagentModelSelection = state.namespaces.get('subagent-model-selection') return (

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} - {subagentModelSelection === undefined - ? null - : ( - - )} {savedIdentity === undefined ? null : ( diff --git a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx deleted file mode 100644 index 8f69f41087..0000000000 --- a/packages/client/ui-settings-models/src/client/SubagentModelSelectionCard.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/** User control for model-selectable subagent delegation in new sessions. */ - -import { useState } from 'react' -import type { ReactNode } from 'react' -import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' -import type { ModelsSettingsStore } from './store.ts' -import type { en } from './locales.ts' -import { messageOf } from './store.ts' -import styles from './ModelsSection.module.css' - -/** Props for the Host-owned subagent model-selection preference. */ -export interface SubagentModelSelectionCardProps { - /** Current redacted namespace view. */ - namespace: SettingsNamespaceView - /** Whether the settings provider accepts writes. */ - writable: boolean - /** Settings wire face. */ - api: SettingsWireFace - /** Models page controller to refresh after a commit. */ - controller: ModelsSettingsStore - /** Localized Models copy. */ - t: (key: keyof typeof en) => string -} - -/** Read the schema-validated resolved boolean from a namespace view. */ -function enabledOf(namespace: SettingsNamespaceView): boolean { - if (typeof namespace.value !== 'object' || namespace.value === null) return false - return (namespace.value as { enabled?: unknown }).enabled === true -} - -/** Render and persist the default-off new-session preference. */ -export function SubagentModelSelectionCard({ - namespace, - writable, - api, - controller, - t, -}: SubagentModelSelectionCardProps): ReactNode { - const [saving, setSaving] = useState(false) - const [saved, setSaved] = useState(false) - const [error, setError] = useState(undefined) - const enabled = enabledOf(namespace) - - const toggle = (): void => { - setSaving(true) - setSaved(false) - setError(undefined) - void api.settings.update( - namespace.ns, - { enabled: !enabled }, - namespace.revision, - ).then(async (response) => { - if (!response.ok) throw new Error(response.error.message) - controller.acceptNamespace(response.value) - await controller.load() - setSaved(true) - }).catch((reason: unknown) => { - setError(messageOf(reason)) - }).finally(() => { setSaving(false) }) - } - - return ( -
-
-

- {t('subagentModelSelectionTitle')} -

-

{t('subagentModelSelectionDescription')}

-
- - {saved - ?

{t('subagentModelSelectionSaved')}

- : null} - {error === undefined ? null :

{error}

} -
- ) -} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 176e33fe5e..f1b0718ba5 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -5,10 +5,6 @@ export const en = { nav: 'Models', title: 'Models', intro: 'Enter your API keys to use models from the following providers.', - subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', - subagentModelSelectionToggle: 'Allow subagents to choose models', - subagentModelSelectionSaved: 'Saved. New sessions use this setting.', edit: 'Edit', editProvider: 'Edit {provider}', remove: 'Delete', @@ -113,10 +109,6 @@ export const zh: { [Key in keyof typeof en]: string } = { nav: '模型', title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', - subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', - subagentModelSelectionToggle: '允许 subagent 自选模型', - subagentModelSelectionSaved: '已保存,新会话将使用此设置。', edit: '编辑', editProvider: '编辑 {provider}', remove: '删除', diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 512a6573e5..da23418375 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -8,7 +8,6 @@ import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' -import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -328,72 +327,6 @@ describe('ModelsSection', () => { expect(screen.getByLabelText(en.keyInput)).toBeTruthy() expect(cardSeatCalls(renderSlot).some(([provider]) => provider === 'anthropic')).toBe(false) }) - - it('persists the default-off subagent model-selection switch for new sessions', async () => { - const enabledNamespace: SettingsNamespaceView = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - user: { enabled: true }, - revision: 5, - } - const update = vi.fn(() => Promise.resolve(remoteOk(enabledNamespace))) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - fireEvent.click(toggle) - - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(update).toHaveBeenCalledWith( - 'subagent-model-selection', - { enabled: true }, - 4, - ) - expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) - }) - - it('reports rejected subagent model-selection updates and permits a retry', async () => { - const update = vi.fn() - .mockResolvedValueOnce(remoteFail('revision changed', 'settings-rejected')) - .mockResolvedValueOnce(remoteOk({ - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: { enabled: true }, - revision: 5, - })) - await mountSection({ update }) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - fireEvent.click(toggle) - expect((await screen.findByRole('alert')).textContent).toBe('revision changed') - - fireEvent.click(toggle) - await waitFor(() => { expect(toggle.getAttribute('aria-checked')).toBe('true') }) - expect(screen.queryByRole('alert')).toBeNull() - }) - - it('keeps malformed and read-only subagent preferences off', () => { - const namespace = { - ...wireNamespaces().find(view => view.ns === 'subagent-model-selection')!, - value: null, - } as unknown as SettingsNamespaceView - const mutate = vi.fn() - render( - , - ) - - const toggle = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) - expect(toggle.getAttribute('aria-checked')).toBe('false') - expect((toggle as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(toggle) - expect(mutate).not.toHaveBeenCalled() - }) - it('renders the unkeyed whole-section provider as an open setup card in the first-run posture', async () => { await mountFirstRun() // Nothing is reachable yet, and DeepSeek has no configured credential and diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 677ca14418..c9745db2b7 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -1,5 +1,5 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -84,6 +84,14 @@ function api(overrides: { } describe('ModelsSettingsStore', () => { + it('forwards accepted writes into the shared settings mirror', () => { + const { face } = api() + const acceptView = vi.fn() + const store = new ModelsSettingsStore(face, settingsSchema, { acceptView } as never) + store.acceptNamespace(NAMESPACES[0]!) + expect(acceptView).toHaveBeenCalledWith(NAMESPACES[0]) + }) + it('joins rows with configured, removable, and credential state', async () => { const { face, mirror, seenRefs } = api() const store = new ModelsSettingsStore(face, settingsSchema, mirror) diff --git a/packages/client/ui-settings-plugins/README.i18n.yaml b/packages/client/ui-settings-plugins/README.i18n.yaml index 150c835953..cd36155215 100644 --- a/packages/client/ui-settings-plugins/README.i18n.yaml +++ b/packages/client/ui-settings-plugins/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-settings-plugins/README.md -README.md: 17ef6fda253ead749eba297519ad4b0647fc482e -README.zh.md: 9f48f673c6d145e0ff1e182d5a47a710a531ba72 +README.md: 8453bd4cb8928bbd88217716f9d505cafaf46b4f +README.zh.md: 6a9b716b843af804b1534f0fd0fa0fc8fb5686c1 diff --git a/packages/client/ui-settings-plugins/README.md b/packages/client/ui-settings-plugins/README.md index 17ef6fda25..8453bd4cb8 100644 --- a/packages/client/ui-settings-plugins/README.md +++ b/packages/client/ui-settings-plugins/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards this package ships cover the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), and the DeepSeek search provider (`web-search-deepseek`). +Open the Plugins section in Settings and select the **Plugin configuration** tab to edit the host-plane plugins this deployment composes. The cards appear in this order: the shell executor (`bash`), the agent loop's tool-call parallelism (`agent-loop`), subagent model selection (`subagent-model-selection`), and the DeepSeek search provider (`web-search-deepseek`). ### What appears here @@ -33,7 +33,9 @@ The tab reads which settings namespaces the Host serves and dispatches one slot ### Editing and saving -A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted — the card reads the section back afterwards and reports a save that did not land, keeping those drafts for the user to correct. +A card stages what the user types and writes it only when they save. Each control renders staged text, so what is on screen is exactly what a save would store; **Discard** drops the drafts, and a card holding unsaved edits says so on its header even while collapsed. A successful save collapses the card after the read-back confirms the writes; a failed save keeps the card open, reports the failure, and retains the drafts for correction. A reset stages the composed default rather than writing immediately, and a draft the field does not accept blocks the save instead of being dropped. The Host is the only authority on whether a value was accepted. + +The Subagent card stages its permission switch and exact model checkboxes together. Enabling requires at least one selected adapter route. Saving submits `enabled` and `allowedModels` in one mutation fenced by the revision where that draft began; a newer Host revision marks the draft failed instead of restoring a revoked route. Disabling retains the selected routes for later reuse. Available models are grouped by provider, while saved routes absent from the current catalog appear last and remain removable. Adapter names and model descriptions remain live directory metadata and are not stored, and the card refreshes them after adapter changes, settings commits, and reconnects. ### Secret-role fields @@ -55,7 +57,7 @@ The section declares `settings.plugins.tab`, a root list slot whose labels becom ### The write path -Saving writes each staged field through the client settings scope, which fences every write with the namespace revision it read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. +Saving writes staged fields through the client settings scope, which fences each write or ordered mutation with the namespace revision the draft read, so a form that has drifted from the document is refused rather than overwriting a concurrent change. A field's presence in the raw user layer — not its value — is what marks it overridden; a reset clears that field so it re-inherits the composition layer. Secret-role fields never ride a response; the card re-reads on the forwarded `credentials/reference-updated` event for the reference it watches. diff --git a/packages/client/ui-settings-plugins/README.zh.md b/packages/client/ui-settings-plugins/README.zh.md index 9f48f673c6..6a9b716b84 100644 --- a/packages/client/ui-settings-plugins/README.zh.md +++ b/packages/client/ui-settings-plugins/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。本包自带的卡片覆盖 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 +打开设置中的「插件」分区并选择**插件配置**标签页,即可编辑本部署所组装的宿主平面插件。卡片依次为 shell 执行器(`bash`)、agent 循环的工具调用并行度(`agent-loop`)、subagent 模型选择(`subagent-model-selection`)以及 DeepSeek 搜索提供方(`web-search-deepseek`)。 ### 这里会出现什么 @@ -33,7 +33,9 @@ kind: "package-reference" ### 编辑与保存 -卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算——卡片在写入后回读分节,报告没有落盘的保存,并保留这些草稿供用户修改。 +卡片暂存用户输入,只有用户保存时才写入。每个控件渲染的都是暂存文本,因此屏幕上所见即保存后所存;**放弃修改**丢弃这些草稿,持有未保存修改的卡片即使收起也会在标题上标明。保存成功后,卡片会在回读确认写入后收起;保存失败时,卡片保持展开、报告失败并保留草稿供用户修改。重置暂存的是组装默认值而非立即写入;字段不接受的草稿会阻塞保存,而不是被丢弃。某个值是否被接受只有 Host 说了算。 + +Subagent 卡会同时暂存其权限开关与精确模型复选框。启用时必须至少选择一条适配器路由。保存会在一次 mutation 中提交 `enabled` 与 `allowedModels`,并以草稿开始时的 revision 设栅;Host revision 更新后,草稿会标记为失败,而不会恢复已撤销的路由。关闭时会保留已选路由供以后重新使用。可用模型按提供方分组;当前目录中缺失的已存路由排在末尾,且仍可移除。适配器名称与模型描述仍属于实时目录元数据,不会存储;适配器变化、设置提交和重连后,卡片会刷新这些元数据。 ### secret 角色字段 @@ -55,7 +57,7 @@ kind: "package-reference" ### 写入路径 -保存时,每个暂存字段都通过客户端 settings scope 写入,该 scope 用读取时的命名空间 revision 为每次写入设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 +保存时,暂存字段通过客户端 settings scope 写入;每次单字段写入或有序 mutation 都以草稿读取时的命名空间 revision 设栅,因此已与文档脱节的表单会被拒绝,而不是覆盖并发变更。字段是否被覆盖,取决于它是否出现在原始用户层中,而非取决于它的值;重置会清除该字段,使其重新继承组装层。secret 角色的字段绝不搭乘响应;卡片会在转发来的 `credentials/reference-updated` 事件报告它所关注的引用时重读。 diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css new file mode 100644 index 0000000000..805ce60fb5 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -0,0 +1,87 @@ +/* Direct preference card inside the configurable Plugins list. */ + +.card { + list-style: none; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px 16px; + padding: 14px 16px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-alias-bg-layer-3); +} + +.copy { + min-width: 0; +} + +.title { + margin: 0; + font-size: 15px; + line-height: 1.4; + font-weight: 600; + color: var(--dsw-alias-label-primary); +} + +.description { + margin: 4px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--dsw-alias-label-tertiary); +} + +.switch { + box-sizing: border-box; + position: relative; + width: 36px; + height: 20px; + padding: 2px; + border: 0; + border-radius: 10px; + background: var(--dsw-alias-border-l3); + cursor: pointer; +} + +.switchOn { + background: var(--dsw-alias-brand-primary); +} + +.switch:disabled { + cursor: default; + opacity: 0.5; +} + +.switch:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 2px; +} + +.thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--dsw-alias-label-primary-foreground); + transition: transform 120ms ease; +} + +.switchOn .thumb { + transform: translateX(16px); +} + +.status, +.failed { + grid-column: 1 / -1; + margin: 0; + font-size: 12px; + line-height: 1.5; +} + +.status { + color: var(--dsw-alias-state-success-primary); +} + +.failed { + color: var(--dsw-alias-label-error); +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx new file mode 100644 index 0000000000..b8858f4ae3 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -0,0 +1,47 @@ +/** User control for model-selectable subagent delegation in new sessions. */ + +import clsx from 'clsx' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts' +import type {} from './slot-contract.ts' +import css from './SubagentModelSelectionCard.module.css' + +/** Props the renderer binds for the subagent model-selection card. */ +export type SubagentModelSelectionCardProps = + PropsRuntime<'settings.plugin.item'> + & PropsLocale<'settings.plugins'> + & InjectFace + +/** + * Render the default-off preference and persist each switch gesture. + * @param props - locale copy, the card snapshot, and its toggle action. + * @returns the preference card, or nothing when the namespace is unavailable. + */ +export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { + const { t } = props + const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + if (!state.available) return null + return ( +
  • +
    +

    + {t('subagentModelSelectionTitle')} +

    +

    {t('subagentModelSelectionDescription')}

    +
    + + {state.saved ?

    {t('subagentModelSelectionSaved')}

    : null} + {state.failed ?

    {t('subagentModelSelectionSaveFailed')}

    : null} +
  • + ) +} diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 11dc449b3e..4ded206103 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -4,7 +4,7 @@ * * The section declares `settings.plugins.tab`; its own `configurable` tab then * declares `settings.plugin.item` and renders whatever cards were registered - * into it. The three cards this package ships are the host-plane sections the + * into it. The cards this package ships are the host-plane sections the * deployment already exposes; each binds its namespace through the client * settings scope, which keeps them unaware of one another and of other tabs. */ @@ -25,10 +25,14 @@ import { BashCard } from './BashCard.tsx' import { ConfigurablePluginsTab } from './ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from './PluginsSettingsSection.tsx' import type { PluginsSettingsSectionInjected, PluginsSettingsTabEntry } from './PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from './SubagentModelSelectionCard.tsx' import { WebSearchCard } from './WebSearchCard.tsx' import { AGENT_LOOP_NS, AgentLoopCardController } from './agent-loop-card-controller.ts' import { SHELL_NS, BashCardController } from './bash-card-controller.ts' import { ConfigurablePluginsTabController } from './tab-store.ts' +import { + SUBAGENT_MODEL_SELECTION_NS, SubagentModelSelectionCardController, +} from './subagent-model-selection-card-controller.ts' import { WEB_SEARCH_NS, WebSearchCardController } from './web-search-card-controller.ts' import { en, zh } from './locales.ts' @@ -44,6 +48,9 @@ export type { export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts' export type { BashCardFace, BashCardState } from './bash-card-controller.ts' export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts' +export type { + SubagentModelSelectionCardFace, SubagentModelSelectionCardState, +} from './subagent-model-selection-card-controller.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.plugins' @@ -63,6 +70,9 @@ export function apply(ctx: ClientContext): void { const agentLoop = new AgentLoopCardController(ctx.settingsScope.bind({ namespace: AGENT_LOOP_NS })) const webSearch = new WebSearchCardController( ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) + const subagentModelSelection = new SubagentModelSelectionCardController( + ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + ) // The credential a card reports is not part of any settings section, so its // scope publishes nothing when one is written. This is the only signal that @@ -71,6 +81,7 @@ export function apply(ctx: ClientContext): void { () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) + ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') // The shared SettingsScope mirror updates after document commits and reconnects. const configurable = new ConfigurablePluginsTabController( @@ -130,7 +141,7 @@ export function apply(ctx: ClientContext): void { }, PluginsSettingsSection)) // The existing configuration page is one ordinary tab. It keeps ownership - // of the card slot and the three shipped card contributions below. + // of the card slot and the shipped card contributions below. ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({ name: 'settings.plugins.tab', id: 'configurable', @@ -142,6 +153,12 @@ export function apply(ctx: ClientContext): void { }, ConfigurablePluginsTab)) ctx.slots.inject('settings.plugin.item', function* () { + yield ctx.slots.register({ + name: 'settings.plugin.item', + key: SUBAGENT_MODEL_SELECTION_NS, + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: SHELL_NS, diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 1478e39997..2debc256b3 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -11,6 +11,8 @@ export type PluginsSettingsLocaleKey = | 'webSearchTitle' | 'webSearchDescription' | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' + | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionSaved' | 'subagentModelSelectionSaveFailed' /** English copy. */ export const en: Record = { @@ -51,6 +53,11 @@ export const en: Record = { webSearchBaseUrlHint: 'Leave blank to use the provider default.', webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', + subagentModelSelectionTitle: 'Subagent model selection', + subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', + subagentModelSelectionToggle: 'Allow subagents to choose models', + subagentModelSelectionSaved: 'Saved. New sessions use this setting.', + subagentModelSelectionSaveFailed: 'The setting could not be saved. Try again.', } /** Simplified Chinese copy. */ @@ -92,4 +99,9 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', + subagentModelSelectionTitle: 'Subagent 自选模型', + subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', + subagentModelSelectionToggle: '允许 subagent 自选模型', + subagentModelSelectionSaved: '已保存,新会话将使用此设置。', + subagentModelSelectionSaveFailed: '设置保存失败,请重试。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts new file mode 100644 index 0000000000..c2ae07bbb8 --- /dev/null +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -0,0 +1,108 @@ +/** Direct preference controller for model-selectable subagent delegation. */ + +import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' + +/** Namespace of the Host-owned subagent model-selection preference. */ +export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' + +/** Settings fields stored for subagent model selection. */ +export interface SubagentModelSelectionSettings { + /** Whether new top-level Sessions may expose child model selection. */ + enabled?: boolean +} + +/** State rendered by the direct preference card. */ +export interface SubagentModelSelectionCardState { + /** Whether the Host serves this namespace. */ + available: boolean + /** Whether the settings document accepts writes. */ + writable: boolean + /** Effective preference; absent values resolve off. */ + enabled: boolean + /** Whether one switch write is crossing the wire. */ + saving: boolean + /** Whether the latest write landed. */ + saved: boolean + /** Whether the latest write settled without changing the Host value. */ + failed: boolean +} + +/** Registration-side face for the subagent model-selection card. */ +export interface SubagentModelSelectionCardFace { + hooks: { + /** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */ + subagentModelSelectionCard: SnapshotStore + } + /** Flip and immediately persist the preference. */ + toggle: () => void +} + +/** Bridges the settings scope onto one immediate-save switch. */ +export class SubagentModelSelectionCardController { + private saving = false + private saved = false + private failed = false + private disposed = false + private generation = 0 + private readonly store: SnapshotStore + private readonly unsubscribe: () => void + + /** @param scope - the bound `subagent-model-selection` settings scope. */ + constructor(private readonly scope: SettingsScope) { + this.store = createSnapshotStore(this.projection()) + this.unsubscribe = scope.subscribe(() => { this.publish() }) + } + + /** Stop observing the settings scope. */ + dispose(): void { + this.disposed = true + this.generation += 1 + this.unsubscribe() + } + + /** + * Build the face injected into the card slot. + * @returns the card snapshot and its direct toggle action. + */ + inject(): SubagentModelSelectionCardFace { + return { + hooks: { subagentModelSelectionCard: this.store }, + toggle: () => { void this.toggle() }, + } + } + + private async toggle(): Promise { + const current = this.scope.getSnapshot() + if (this.disposed || current.status !== 'ready' || !current.writable || this.saving) return + const desired = current.value?.enabled !== true + const generation = this.generation + this.saving = true + this.saved = false + this.failed = false + this.publish() + await this.scope.set('enabled', desired) + if (generation !== this.generation) return + const landed = this.scope.getSnapshot().value?.enabled === desired + this.saving = false + this.saved = landed + this.failed = !landed + this.publish() + } + + private projection(): SubagentModelSelectionCardState { + const snapshot = this.scope.getSnapshot() + return { + available: snapshot.status === 'ready', + writable: snapshot.writable, + enabled: snapshot.value?.enabled === true, + saving: this.saving, + saved: this.saved, + failed: this.failed, + } + } + + private publish(): void { + this.store.set(this.projection()) + } +} diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index bba43edcbc..a764446096 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -117,7 +117,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['shell', 'agent-loop', 'web-search-deepseek']) + .toEqual(['subagent-model-selection', 'shell', 'agent-loop', 'web-search-deepseek']) }) it('dispatches the served namespaces its cards claim, and no others', async () => { @@ -203,7 +203,7 @@ describe('ui-settings-plugins apply', () => { declareRoot(slots) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - expect(slots.entries('settings.plugin.item')).toHaveLength(3) + expect(slots.entries('settings.plugin.item')).toHaveLength(4) await fiber.dispose() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index ad666e9093..13c69a945f 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -12,6 +12,8 @@ import { ConfigurablePluginsTab } from '../src/client/ConfigurablePluginsTab.tsx import type { ConfigurablePluginsTabProps } from '../src/client/ConfigurablePluginsTab.tsx' import { PluginsSettingsSection } from '../src/client/PluginsSettingsSection.tsx' import type { PluginsSettingsSectionProps, PluginsSettingsTabEntry } from '../src/client/PluginsSettingsSection.tsx' +import { SubagentModelSelectionCard } from '../src/client/SubagentModelSelectionCard.tsx' +import type { SubagentModelSelectionCardProps } from '../src/client/SubagentModelSelectionCard.tsx' import { WebSearchCard } from '../src/client/WebSearchCard.tsx' import type { WebSearchCardProps } from '../src/client/WebSearchCard.tsx' import type { AgentLoopCardState } from '../src/client/agent-loop-card-controller.ts' @@ -19,6 +21,7 @@ import type { BashCardState } from '../src/client/bash-card-controller.ts' import type { CardFieldState, CardShell } from '../src/client/card-form.ts' import type { ConfigurablePluginsTabState } from '../src/client/tab-store.ts' import type { WebSearchCardState } from '../src/client/web-search-card-controller.ts' +import type { SubagentModelSelectionCardState } from '../src/client/subagent-model-selection-card-controller.ts' import { en } from '../src/client/locales.ts' afterEach(cleanup) @@ -79,6 +82,26 @@ function renderBash(state: Partial = {}) { return actions } +function renderSubagentModelSelection(state: Partial = {}) { + const store = createSnapshotStore({ + available: true, + writable: true, + enabled: false, + saving: false, + saved: false, + failed: false, + ...state, + }) + const toggle = vi.fn() + const props = { + t, + toggle, + useSubagentModelSelectionCard: bindSnapshotSelector(store), + } as unknown as SubagentModelSelectionCardProps + render() + return toggle +} + describe('PluginsSettingsSection', () => { it('says so when no plugin contributed a tab', () => { renderSection([]) @@ -294,6 +317,40 @@ describe('BashCard', () => { }) }) +describe('SubagentModelSelectionCard', () => { + it('renders the default-off preference directly in the Plugins list', () => { + const toggle = renderSubagentModelSelection() + + const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) + expect(control.getAttribute('aria-checked')).toBe('false') + fireEvent.click(control) + + expect(toggle).toHaveBeenCalledOnce() + }) + + it('reports successful and rejected writes', () => { + renderSubagentModelSelection({ enabled: true, saved: true }) + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') + expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + + cleanup() + renderSubagentModelSelection({ failed: true }) + expect(screen.getByRole('alert').textContent).toBe(en.subagentModelSelectionSaveFailed) + }) + + it('stays hidden when unavailable and disables writes when read-only', () => { + renderSubagentModelSelection({ available: false }) + expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() + + cleanup() + const toggle = renderSubagentModelSelection({ writable: false }) + const control = screen.getByRole('switch') as HTMLButtonElement + expect(control.disabled).toBe(true) + fireEvent.click(control) + expect(toggle).not.toHaveBeenCalled() + }) +}) + describe('AgentLoopCard', () => { it('stages and saves the only field it owns', () => { const store = createSnapshotStore({ diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 505079be0c..a1554aa3c6 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -12,6 +12,9 @@ import { SettingsDescribeMirror, type SettingsMirrorSnapshot, } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' +import { + SubagentModelSelectionCardController, type SubagentModelSelectionSettings, +} from '../src/client/subagent-model-selection-card-controller.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' /** Make the stub behave like a Host that accepts every write. */ @@ -383,6 +386,86 @@ describe('AgentLoopCardController', () => { }) }) +describe('SubagentModelSelectionCardController', () => { + it('immediately writes a switch gesture and reports the accepted value', async () => { + const host = stubSettingsScope() + acceptWrites(host) + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const face = controller.inject() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + face.toggle() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, + saving: false, + saved: true, + failed: false, + }) + }) + + it('keeps the Host value and reports a rejected write', async () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const face = controller.inject() + + face.toggle() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true) + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, + saving: false, + saved: false, + }) + }) + + it('ignores writes while read-only and scope notifications after disposal', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: false, value: { enabled: false }, user: {} }) + const face = controller.inject() + + face.toggle() + expect(host.set).not.toHaveBeenCalled() + + controller.dispose() + face.toggle() + host.publish({ value: { enabled: true } }) + expect(host.set).not.toHaveBeenCalled() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + }) + + it('publishes no settlement after disposal interrupts an in-flight write', async () => { + const host = stubSettingsScope() + let settle = (): void => {} + const pending = new Promise((resolve) => { settle = () => { resolve() } }) + host.set.mockReturnValue(pending) + const controller = new SubagentModelSelectionCardController(host.scope) + host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const face = controller.inject() + + face.toggle() + await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true) + + controller.dispose() + settle() + await Promise.resolve() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, + saving: true, + saved: false, + failed: false, + }) + }) +}) + describe('WebSearchCardController', () => { it('reads the credential state for the reference the tab names', async () => { const host = stubSettingsScope() diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 9dc0c9f3fe..4540e2b743 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1715,6 +1715,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted', occupants: [ + 'client-ui-settings-plugins SubagentModelSelectionCard', 'client-ui-settings-plugins BashCard', 'client-ui-settings-plugins AgentLoopCard', 'client-ui-settings-plugins WebSearchCard', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3d69da2cc3..faa9422877 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -470,7 +470,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true }) }, note: - 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Models preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', + 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', From aefc083be7a02cb9dd97032a3a229c270909cb8a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Mon, 24 Aug 2026 21:49:40 +0800 Subject: [PATCH 031/130] feat(subagent): authorize selectable child models --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 10 +- ...08-18-model-selected-subagent-routes.zh.md | 10 +- ...authorized-subagent-model-routes.i18n.yaml | 6 + ...4-user-authorized-subagent-model-routes.md | 42 +++ ...ser-authorized-subagent-model-routes.zh.md | 42 +++ .../plugin-config/section.expected.md | 8 +- apps/web/tests/plugin-config.e2e.ts | 14 +- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 11 +- docs/persistence-catalog.zh.md | 11 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 6 +- docs/subsystems/subagent.zh.md | 6 +- .../SubagentModelSelectionCard.module.css | 128 +++++--- .../src/client/SubagentModelSelectionCard.tsx | 95 ++++-- .../ui-settings-plugins/src/client/index.ts | 1 + .../ui-settings-plugins/src/client/locales.ts | 31 +- ...ubagent-model-selection-card-controller.ts | 277 +++++++++++++++--- .../tests/section.client.spec.tsx | 90 +++++- .../tests/stores.client.spec.ts | 227 +++++++++++--- .../core/session/src/known-event-types.ts | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 10 +- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 37 ++- .../subagent/tool-subagent/src/invariant.ts | 6 +- .../subagent/tool-subagent/src/list-models.ts | 17 +- .../src/model-selection-settings.ts | 31 +- .../src/model-selection-state.ts | 32 +- .../tool-subagent/src/model-selection.ts | 72 +++++ .../tool-subagent/tests/list-models.spec.ts | 31 ++ .../tests/model-selection-settings.spec.ts | 85 ++++-- .../tests/model-selection.spec.ts | 49 ++++ scripts/gen-cordis-catalog.ts | 1 + 38 files changed, 1154 insertions(+), 258 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md create mode 100644 .agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index ee76abc897..eed424a054 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: c6e4ad70571d70b6a9a7d803b2b372e79599b184 -2026-08-18-model-selected-subagent-routes.zh.md: 6fac3f60e19ce824eb07ae2df83bdd05c7e75090 +2026-08-18-model-selected-subagent-routes.md: 9c6d777a1dbed569340e4900559a2cee2a42ff82 +2026-08-18-model-selected-subagent-routes.zh.md: a6d665f9533b481edf2f91071f7561544744d202 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index c6e4ad7057..9c6d777a1d 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -12,13 +12,13 @@ The model also needs a bounded way to discover live providers and model-owned ef ## Decision -`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves an enabled Session decision, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. No route allowlist is required. Registered LLM provider routes are available for child selection; this tool does not add a second authorization policy over the deployment's LLM registry. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves a non-empty Session policy, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. Static enablement needs no route list and can select any route its adapter accepts. The shipped settings-controlled path uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the Host-owned `subagent-model-selection` settings namespace with `enabled: false`. The Plugins settings page exposes that namespace as a direct switch. A new top-level Session samples that preference during composition and logs an enabled decision as `subagent/model-selection-enabled` before any model request. A child Session inherits the live parent's decision, and a resumed Session uses its existing marker instead of the current preference. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. An unlisted model remains selectable when the adapter accepts its id. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Static enablement exposes the live directory without another filter. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-empty Host-owned `subagent-model-selection.allowedModels` setting. The Plugins settings page stores exact provider/model routes from the adapter directory. A new top-level Session snapshots a non-empty policy as `subagent/model-selection-policy` before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. In settings-controlled Sessions, discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. @@ -28,7 +28,7 @@ The delegation definition is static across adapter registration and catalog chan ## Alternatives considered -**Keep a deployment-configured route allowlist.** Rejected because it duplicates the live LLM registry, requires configuration before the model can use an already registered route, and creates a second policy surface for clients to edit. Deployments that must restrict LLM access should control which provider routes they register. +**Require a deployment-configured route allowlist for static enablement.** Rejected because it duplicates the live LLM registry and requires configuration before a custom composition can use an already registered route. The shipped user-owned preference is a distinct authorization decision and is documented by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). **Render the live adapter catalog in every delegation description.** Rejected because one provider can advertise hundreds of models, inflating every request, and catalog changes would rewrite an early cache-prefix definition. The on-demand directory keeps mutable data out of the fixed schema. @@ -48,8 +48,8 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences -- An enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. -- The primary delegation-tool instance defaults selection off, exposes a Models-page opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable decision is enabled; its catalog rows do not restrict delegation. +- A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. +- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy is non-empty; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 6fac3f60e1..a6d665f953 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出已启用的 Session 决定,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,不要求配置路由允许列表。已注册的 LLM 提供方路由都可供子级选择;本工具不会在部署的 LLM 注册表之上增加第二套授权策略。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 +只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出非空 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。静态启用无需路由列表,并且可以选择适配器接受的任意路由。随附的 settings 控制路径使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认 `enabled: false` 的 Host 自有 `subagent-model-selection` settings namespace。插件设置页将该命名空间显示为直接开关。新的顶层 Session 会在组合期间读取该偏好,并在任何模型请求之前把启用决定记录为 `subagent/model-selection-enabled`。子 Session 继承在线父级的决定;恢复的 Session 使用已有标记,而不是当前偏好。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。只要适配器接受某个未列出的模型 ID,仍可选择该模型。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。静态启用会公开不带额外过滤的实时目录。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认空值的 Host 自有 `subagent-model-selection.allowedModels` 设置。Plugins 设置页从适配器目录保存精确 provider/model 路由。新的顶层 Session 会在任何模型请求之前,把非空策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。在 settings 控制的 Session 中,发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 @@ -28,7 +28,7 @@ Status: implemented ## 考虑过的替代方案 -**保留部署配置的路由允许列表。** 不采用,因为它重复实时 LLM 注册表,要求先配置才能让模型使用已经注册的路由,并为客户端增加第二套策略编辑界面。需要限制 LLM 访问的部署应控制所注册的提供方路由。 +**要求静态启用配置部署路由允许列表。** 不采用,因为它会重复实时 LLM 注册表,并要求自定义组合先配置才能使用已经注册的路由。随附的用户自有偏好属于另一项授权决定,由[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)记录。 **在每一份委派描述中渲染实时 adapter 目录。** 不采用,因为一个提供方可能公布数百个模型,从而扩大每次请求,而且目录变化会改写缓存前缀中的早期定义。按需目录让可变数据留在固定 schema 之外。 @@ -48,8 +48,8 @@ Status: implemented ## 结果 -- 启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 -- 主委派工具实例默认关闭选择,为新 Session 提供 Models 页面 opt-in,并且只在持久决定已启用的 Session 中注册 `list_subagent_models`;其目录条目不会限制委派。 +- 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略非空的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml new file mode 100644 index 0000000000..b9966ef055 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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/feature/2026-08-24-user-authorized-subagent-model-routes.md +2026-08-24-user-authorized-subagent-model-routes.md: 3a56e48b35bcd1b1801108022e85ec83ad98b437 +2026-08-24-user-authorized-subagent-model-routes.zh.md: dca788993f1eccb96a424d2bd8186749363488b3 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md new file mode 100644 index 0000000000..3a56e48b35 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -0,0 +1,42 @@ +# Agent Note: User-authorized subagent model routes + +Status: implemented + +English | [中文](2026-08-24-user-authorized-subagent-model-routes.zh.md) + +## Problem + +Registering an LLM adapter makes its routes reachable, but does not authorize an Agent to choose every reachable model for a child. A single enabled preference over the live adapter registry expands silently when another provider or model appears. The product needs an explicit, stable authorization decision without rendering a potentially large model directory into every parent request. + +## Decision + +The Host-owned `subagent-model-selection` settings section stores `allowedModels`, an array of exact `{ provider, model }` routes. An empty array disables model-facing child route selection. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage one or more exact routes, and replaces the whole array in one revision-fenced field write. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. + +A newly composed top-level Session snapshots a non-empty route list in `subagent/model-selection-policy` before its model-selectable definitions can reach a request. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions. + +The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. + +Static `enableModelSelection: true` remains an unrestricted deployment-owned mode for custom compositions. The shipped `modelSelectionSettings` path is user-authorized and default-off. The primary spawn tool uses that path; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. + +## Alternatives considered + +**Render the allowed routes in the delegation description.** Rejected because a large or changing list would enlarge every request and invalidate an early prompt prefix. On-demand discovery keeps the fixed schema prefix-stable and logs directory content only when requested. + +**Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child. + +**Store `enabled` and `allowedModels` as separate fields.** Rejected because two writes admit an enabled state with no completed authorization decision. A non-empty array is both the opt-in and its exact policy; an empty user-layer array can explicitly disable a deployment base list. + +**Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized. + +**Read current settings on every discovery or delegation call.** Rejected because a settings edit would silently change a running Session's model-visible capabilities and execution authority. The durable Session snapshot keeps resume and child inheritance deterministic. + +## Consequences + +- New adapter registrations and newly advertised models do not expand user authorization. +- Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. +- The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. +- Unit coverage pins settings validation, Session sampling and inheritance, discovery intersection, executor denial, stale UI candidates, staged whole-array writes, and rejected-write draft preservation. The assembled Web scenario pins the real settings document and Plugins card flow. + +## Related decisions + +The route arguments, adapter preflight, discovery tool, and fork cache restriction remain owned by [model-selected subagent routes](2026-08-18-model-selected-subagent-routes.md). diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md new file mode 100644 index 0000000000..dca788993f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -0,0 +1,42 @@ +# Agent Note: 用户授权的 subagent 模型路由 + +Status: implemented + +[English](2026-08-24-user-authorized-subagent-model-routes.md) | 中文 + +## Problem + +注册 LLM 适配器会使其路由可达,但不代表授权 Agent 为子级选择每一个可达模型。针对实时适配器注册表的单一启用偏好,会在另一个提供方或模型出现时静默扩大范围。产品需要一项显式且稳定的授权决定,同时避免把可能很大的模型目录渲染进父 Agent 的每次请求。 + +## Decision + +Host 自有的 `subagent-model-selection` 设置 section 保存 `allowedModels`,即由精确 `{ provider, model }` 路由组成的数组。空数组会关闭面向模型的子级路由选择。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存一条或多条精确路由,再用一次带 revision 限制的字段写入整体替换该数组。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 + +新组合的顶层 Session 会在模型可选定义进入请求之前,把非空路由列表快照记录为 `subagent/model-selection-policy`。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。 + +固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 + +静态 `enableModelSelection: true` 继续作为自定义组合中由部署方所有的无限制模式。随附的 `modelSelectionSettings` 路径由用户授权且默认关闭。主 spawn 工具使用该路径;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 + +## Alternatives considered + +**在委派描述中渲染允许路由。** 不采用,因为很大或变化的列表会扩大每次请求,并使较早的提示词前缀失效。按需发现会保持固定 schema 的前缀稳定,且只在请求目录时记录其内容。 + +**只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。 + +**把 `enabled` 与 `allowedModels` 存成两个字段。** 不采用,因为两次写入会产生已经启用但尚无完整授权决定的状态。非空数组同时表示 opt-in 与精确策略;用户层空数组可以显式关闭部署基础列表。 + +**保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。 + +**每次发现或委派调用都读取当前设置。** 不采用,因为设置编辑会静默改变运行中 Session 的模型可见能力和执行权限。持久 Session 快照会让恢复与子级继承保持确定。 + +## Consequences + +- 新适配器注册和新公布模型不会扩大用户授权。 +- 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 +- 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 +- 单元覆盖固定设置校验、Session 取样与继承、发现交集、执行器拒绝、UI 陈旧候选项、暂存后的整数组写入,以及写入被拒时保留草稿。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 + +## Related decisions + +路由参数、适配器预检、发现工具与 fork 缓存限制仍由[模型选择的 subagent 路由](2026-08-18-model-selected-subagent-routes.zh.md)负责。 diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 6c17e68db9..39cfa33d85 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -24,10 +24,10 @@ - tab "插件列表" - tabpanel "插件配置": - list: - - listitem "Subagent 自选模型": - - heading "Subagent 自选模型" [level=3] - - paragraph: 允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。 - - switch "允许 subagent 自选模型" + - listitem: + - 'button "展开设置: Subagent 自选模型"': + - text: Subagent 自选模型 选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。 + - img - listitem: - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index ab8515d323..781034541b 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -78,7 +78,7 @@ describe('web e2e: plugin configuration section', () => { // Every card the shipped web composition exposes: subagent selection, the // shell executor, the agent loop, and the DeepSeek search provider. await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 }) - expect(await dialog.getByRole('switch', { name: '允许 subagent 自选模型' }).getAttribute('aria-checked')).toBe('false') + expect(await dialog.getByRole('button', { name: '展开设置: Subagent 自选模型' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -90,17 +90,25 @@ describe('web e2e: plugin configuration section', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('immediately persists the subagent model-selection preference', async () => { + it('persists selected adapter routes as the subagent model allowlist', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) const dialog = await openPlugins() + await dialog.getByText('Subagent 自选模型', { exact: true }).click() const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' }) await toggle.click() + const models = dialog.getByRole('group', { name: '允许的模型' }) + await models.waitFor({ timeout: 10_000 }) + const firstModel = models.getByRole('checkbox').first() + await firstModel.check() + await dialog.getByRole('button', { name: '保存', exact: true }).click() await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) - expect(await settingsDocument()).toContain('enabled: true') + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 4cdad8558b..191f1c452c 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 7f85b870bd9604ed983b0d8a251a3ee4511a52b7 +config-catalog.md: 6432f5d359027a30fd436c9becd6bb6b8c0abaf4 config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7f85b870bd..6432f5d359 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2918,7 +2918,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 13c2a19a3c..6a61b25f17 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: b9839833308afbd0d561bc227a95b236a10238a1 -persistence-catalog.zh.md: 7b5fa938b23ea9112e370133bf7575c8d689806d +persistence-catalog.md: 6a48b9c674375c6b5fa8b296afb7658a9d508168 +persistence-catalog.zh.md: 367b1c1a11324cea057ff03d0c456d531edc0183 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index b983983330..6a48b9c674 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -738,9 +738,9 @@ Source: [`packages/core/session/src/types.ts:239`](../packages/core/session/src/ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — log-only ```ts persistence-catalog /** @@ -749,10 +749,13 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 7b5fa938b2..367b1c1a11 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -740,9 +740,9 @@ export type SessionEvent = { 来源:[`packages/subagent/subagent/src/descriptor.ts:38`](../packages/subagent/subagent/src/descriptor.ts) - + -#### `subagent/model-selection-enabled` — log-only +#### `subagent/model-selection-policy` — 仅日志 ```ts persistence-catalog /** @@ -751,10 +751,13 @@ export type SessionEvent = { * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ -'subagent/model-selection-enabled': Record +'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] +} ``` -来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:13`](../packages/subagent/tool-subagent/src/model-selection-state.ts) +来源:[`packages/subagent/tool-subagent/src/model-selection-state.ts:14`](../packages/subagent/tool-subagent/src/model-selection-state.ts) ### `team/*` diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index f4414983b1..a9f6e2035a 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: f854711f1161ba1c533fdbc43d7d6c35681f2f7f -subagent.zh.md: 960d9b099d915fcf5b1e321ab74374664bb8e468 +subagent.md: 792e03aa8bc5b8533094b4fd678ef8b43383e043 +subagent.zh.md: 1b5af32dfc6405c1df09d38628332e8e6c8ae737 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index f854711f11..792e03aa8b 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached route policy for the next eligible Agent publication. + * @returns exact allowed routes; an empty list disables model-facing selection. */ -currentEnabled(): boolean +currentAllowedModels(): AllowedModelRoute[] ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 960d9b099d..1b5af32dfc 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached route policy for the next eligible Agent publication. + * @returns exact allowed routes; an empty list disables model-facing selection. */ -currentEnabled(): boolean +currentAllowedModels(): AllowedModelRoute[] ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 805ce60fb5..075564224a 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -1,39 +1,16 @@ -/* Direct preference card inside the configurable Plugins list. */ - -.card { - list-style: none; - display: grid; - grid-template-columns: minmax(0, 1fr) auto; +.toggleRow { + display: flex; align-items: center; - gap: 8px 16px; - padding: 14px 16px; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 12px; - background: var(--dsw-alias-bg-layer-3); -} - -.copy { - min-width: 0; -} - -.title { - margin: 0; - font-size: 15px; - line-height: 1.4; - font-weight: 600; - color: var(--dsw-alias-label-primary); -} - -.description { - margin: 4px 0 0; + justify-content: space-between; + gap: 16px; font-size: 13px; - line-height: 1.5; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); } .switch { box-sizing: border-box; position: relative; + flex: 0 0 auto; width: 36px; height: 20px; padding: 2px; @@ -70,18 +47,103 @@ transform: translateX(16px); } -.status, -.failed { - grid-column: 1 / -1; +.selection { + display: grid; + gap: 10px; +} + +.hint, +.notice, +.invalid, +.status { margin: 0; font-size: 12px; line-height: 1.5; } +.hint, +.notice { + color: var(--dsw-alias-label-tertiary); +} + +.invalid { + color: var(--dsw-alias-label-error); +} + .status { color: var(--dsw-alias-state-success-primary); } -.failed { +.catalogError { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; color: var(--dsw-alias-label-error); } + +.catalogError button { + border: 0; + padding: 0; + background: transparent; + color: var(--dsw-alias-brand-primary); + cursor: pointer; +} + +.models { + display: grid; + gap: 6px; + min-width: 0; + max-height: 280px; + margin: 0; + padding: 10px; + overflow: auto; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 8px; +} + +.models legend { + padding: 0 4px; + font-size: 12px; + color: var(--dsw-alias-label-secondary); +} + +.model { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + min-width: 0; + padding: 6px; + border-radius: 6px; + cursor: pointer; +} + +.model:hover { + background: var(--dsw-alias-bg-layer-4); +} + +.modelName, +.route { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modelName { + font-size: 13px; + color: var(--dsw-alias-label-primary); +} + +.route { + margin-top: 2px; + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} + +.unavailable { + font-size: 11px; + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index b8858f4ae3..0a8ce094fd 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -4,6 +4,7 @@ import clsx from 'clsx' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts' import type {} from './slot-contract.ts' +import { PluginCard } from './PluginCard.tsx' import css from './SubagentModelSelectionCard.module.css' /** Props the renderer binds for the subagent model-selection card. */ @@ -13,35 +14,87 @@ export type SubagentModelSelectionCardProps = & InjectFace /** - * Render the default-off preference and persist each switch gesture. + * Render the default-off preference and its exact adapter-route choices. * @param props - locale copy, the card snapshot, and its toggle action. * @returns the preference card, or nothing when the namespace is unavailable. */ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { const { t } = props const state = props.useSubagentModelSelectionCard(snapshot => snapshot) - if (!state.available) return null return ( -
  • -
    -

    - {t('subagentModelSelectionTitle')} -

    -

    {t('subagentModelSelectionDescription')}

    + +
    + {t('subagentModelSelectionToggle')} +
    - + {state.enabled + ? ( +
    +

    {t('subagentModelSelectionChoose')}

    + {state.catalogStatus === 'loading' + ?

    {t('subagentModelSelectionLoading')}

    + : null} + {state.catalogStatus === 'error' + ? ( +
    + {t('subagentModelSelectionLoadFailed')} + +
    + ) + : null} + {state.catalogFailures.length > 0 + ?

    {t('subagentModelSelectionPartial')}

    + : null} + {state.candidates.length > 0 + ? ( +
    + {t('subagentModelSelectionAllowed')} + {state.candidates.map(candidate => ( + + ))} +
    + ) + : state.catalogStatus === 'ready' + ?

    {t('subagentModelSelectionEmpty')}

    + : null} + {state.invalid ?

    {t('subagentModelSelectionRequired')}

    : null} +
    + ) + :

    {t('subagentModelSelectionOff')}

    } {state.saved ?

    {t('subagentModelSelectionSaved')}

    : null} - {state.failed ?

    {t('subagentModelSelectionSaveFailed')}

    : null} -
  • + ) } diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 4ded206103..6533d97fc0 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -72,6 +72,7 @@ export function apply(ctx: ClientContext): void { ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) const subagentModelSelection = new SubagentModelSelectionCardController( ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), + api, ) // The credential a card reports is not part of any settings section, so its diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 2debc256b3..24a3424a01 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -12,7 +12,10 @@ export type PluginsSettingsLocaleKey = | 'webSearchApiKey' | 'webSearchApiKeyHint' | 'webSearchApiKeySet' | 'webSearchApiKeyUnset' | 'webSearchBaseUrl' | 'webSearchBaseUrlHint' | 'webSearchMaxUses' | 'webSearchMaxUsesHint' | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' - | 'subagentModelSelectionToggle' | 'subagentModelSelectionSaved' | 'subagentModelSelectionSaveFailed' + | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' + | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' | 'subagentModelSelectionSaved' /** English copy. */ export const en: Record = { @@ -54,10 +57,19 @@ export const en: Record = { webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Allow new sessions to choose a provider, model, and reasoning effort for subagents. Running sessions do not change.', + subagentModelSelectionDescription: 'Choose which child models new sessions may select. Running sessions do not change.', subagentModelSelectionToggle: 'Allow subagents to choose models', + subagentModelSelectionChoose: 'Select at least one model. Only these adapter routes appear in subagent discovery.', + subagentModelSelectionAllowed: 'Allowed models', + subagentModelSelectionLoading: 'Loading adapter models…', + subagentModelSelectionLoadFailed: 'Adapter models could not be loaded.', + subagentModelSelectionRetry: 'Retry', + subagentModelSelectionPartial: 'Some providers could not list their models; stored choices remain removable.', + subagentModelSelectionUnavailable: 'Unavailable', + subagentModelSelectionEmpty: 'No adapter currently advertises a model.', + subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionOff: 'New sessions inherit the configured or parent model without choosing another route.', subagentModelSelectionSaved: 'Saved. New sessions use this setting.', - subagentModelSelectionSaveFailed: 'The setting could not be saved. Try again.', } /** Simplified Chinese copy. */ @@ -100,8 +112,17 @@ export const zh: Record = { webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '允许新会话为 subagent 选择提供方、模型和推理强度。运行中的会话不会改变。', + subagentModelSelectionDescription: '选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。', subagentModelSelectionToggle: '允许 subagent 自选模型', + subagentModelSelectionChoose: '请至少选择一个模型。Subagent 发现工具只会列出这些 adapter 路由。', + subagentModelSelectionAllowed: '允许的模型', + subagentModelSelectionLoading: '正在加载 adapter 模型…', + subagentModelSelectionLoadFailed: '无法加载 adapter 模型。', + subagentModelSelectionRetry: '重试', + subagentModelSelectionPartial: '部分提供方无法列出模型;仍可移除已保存的选项。', + subagentModelSelectionUnavailable: '不可用', + subagentModelSelectionEmpty: '当前没有 adapter 公布模型。', + subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionOff: '新会话会使用配置值或继承父 Agent 模型,不会自主选择其他路由。', subagentModelSelectionSaved: '已保存,新会话将使用此设置。', - subagentModelSelectionSaveFailed: '设置保存失败,请重试。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index c2ae07bbb8..d8cfb62f6c 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -1,31 +1,55 @@ -/** Direct preference controller for model-selectable subagent delegation. */ +/** Staged editor for the Host-owned subagent model allowlist. */ +import type { + IApiClient, + ModelCatalogFailure, + ModelProviderGroup, +} from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' +import type { CardShell } from './card-form.ts' /** Namespace of the Host-owned subagent model-selection preference. */ export const SUBAGENT_MODEL_SELECTION_NS = 'subagent-model-selection' -/** Settings fields stored for subagent model selection. */ -export interface SubagentModelSelectionSettings { - /** Whether new top-level Sessions may expose child model selection. */ - enabled?: boolean +/** One exact provider/model route stored as user authorization. */ +export interface AllowedSubagentModel { + provider: string + model: string } -/** State rendered by the direct preference card. */ -export interface SubagentModelSelectionCardState { - /** Whether the Host serves this namespace. */ +/** Settings fields stored for subagent model selection. */ +export interface SubagentModelSelectionSettings { + /** Exact child routes offered to newly composed top-level Sessions. */ + allowedModels?: AllowedSubagentModel[] +} + +/** One catalog row joined with a stored route that may no longer be advertised. */ +export interface SubagentModelCandidate extends AllowedSubagentModel { + /** Stable opaque identity used only for lookup. */ + key: string + /** Adapter-owned provider display name. */ + providerName: string + /** Adapter-owned model display name. */ + modelName: string + /** Whether the current adapter catalog advertises this exact route. */ available: boolean - /** Whether the settings document accepts writes. */ - writable: boolean - /** Effective preference; absent values resolve off. */ + /** Whether the current draft authorizes this route. */ + selected: boolean +} + +/** State rendered by the staged allowlist card. */ +export interface SubagentModelSelectionCardState extends CardShell { + /** Whether the draft enables model-facing child route selection. */ enabled: boolean - /** Whether one switch write is crossing the wire. */ - saving: boolean - /** Whether the latest write landed. */ + /** Live catalog joined with stored routes. */ + candidates: readonly SubagentModelCandidate[] + /** Adapter-directory request state. */ + catalogStatus: 'idle' | 'loading' | 'ready' | 'error' + /** Provider-local failures that did not block other candidates. */ + catalogFailures: readonly ModelCatalogFailure[] + /** Whether the latest save landed. */ saved: boolean - /** Whether the latest write settled without changing the Host value. */ - failed: boolean } /** Registration-side face for the subagent model-selection card. */ @@ -34,71 +58,248 @@ export interface SubagentModelSelectionCardFace { /** Card snapshot bound by the renderer as useSubagentModelSelectionCard. */ subagentModelSelectionCard: SnapshotStore } - /** Flip and immediately persist the preference. */ - toggle: () => void + /** Stage the enabled state; enabling also loads the adapter directory. */ + toggleEnabled: () => void + /** Stage one exact route as allowed or denied. */ + toggleModel: (key: string) => void + /** Retry the adapter directory. */ + retryCatalog: () => void + /** Persist the whole exact route list as one revision-fenced field write. */ + save: () => void + /** Drop the staged enabled state and route choices. */ + discard: () => void } -/** Bridges the settings scope onto one immediate-save switch. */ +/** + * Stable identity for one exact route; callers resolve it by lookup and never parse it. + * @param route - Provider/model route to identify. + * @returns Opaque key for lookup within the card. + */ +export function subagentModelKey(route: AllowedSubagentModel): string { + return `${route.provider}\0${route.model}` +} + +/** + * Join live adapter metadata with stored routes that remain removable after disappearance. + * @param groups - Current model directory grouped by provider. + * @param stored - Routes in the effective settings value. + * @param selected - Opaque route keys selected in the current draft. + * @returns Candidate rows for the card. + */ +export function subagentModelCandidates( + groups: readonly ModelProviderGroup[], + stored: readonly AllowedSubagentModel[], + selected: ReadonlySet, +): SubagentModelCandidate[] { + const storedByKey = new Map(stored.map(route => [subagentModelKey(route), route])) + const candidates = groups.flatMap(group => group.models.map((model): SubagentModelCandidate => { + const route = { provider: group.id, model: model.id } + const key = subagentModelKey(route) + storedByKey.delete(key) + return { + ...route, + key, + providerName: group.name, + modelName: model.name, + available: true, + selected: selected.has(key), + } + })) + for (const route of storedByKey.values()) { + const key = subagentModelKey(route) + candidates.push({ + ...route, + key, + providerName: route.provider, + modelName: route.model, + available: false, + selected: selected.has(key), + }) + } + return candidates +} + +function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly AllowedSubagentModel[]): boolean { + if (left.length !== right.length) return false + const rightKeys = new Set(right.map(subagentModelKey)) + return left.every(route => rightKeys.has(subagentModelKey(route))) +} + +/** Bridges one settings scope and the live adapter directory onto a staged card. */ export class SubagentModelSelectionCardController { + private catalogGroups: readonly ModelProviderGroup[] = [] + private catalogFailures: readonly ModelCatalogFailure[] = [] + private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' + private draftEnabled: boolean | undefined + private draftSelected: Set | undefined private saving = false private saved = false private failed = false private disposed = false - private generation = 0 + private saveGeneration = 0 + private catalogGeneration = 0 private readonly store: SnapshotStore private readonly unsubscribe: () => void - /** @param scope - the bound `subagent-model-selection` settings scope. */ - constructor(private readonly scope: SettingsScope) { + /** + * @param scope - bound `subagent-model-selection` settings scope. + * @param api - Host LLM directory face. + */ + constructor( + private readonly scope: SettingsScope, + private readonly api: Pick, + ) { this.store = createSnapshotStore(this.projection()) - this.unsubscribe = scope.subscribe(() => { this.publish() }) + this.unsubscribe = scope.subscribe(() => { + if (this.currentRoutes().length > 0 && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + }) } - /** Stop observing the settings scope. */ + /** Stop observing settings and suppress late directory/write settlements. */ dispose(): void { this.disposed = true - this.generation += 1 + this.saveGeneration += 1 + this.catalogGeneration += 1 this.unsubscribe() } /** - * Build the face injected into the card slot. - * @returns the card snapshot and its direct toggle action. + * Build the renderer face for this card. + * @returns The snapshot and staged card actions injected into the renderer. */ inject(): SubagentModelSelectionCardFace { return { hooks: { subagentModelSelectionCard: this.store }, - toggle: () => { void this.toggle() }, + toggleEnabled: () => { this.toggleEnabled() }, + toggleModel: (key) => { this.toggleModel(key) }, + retryCatalog: () => { void this.loadCatalog() }, + save: () => { void this.save() }, + discard: () => { this.discard() }, } } - private async toggle(): Promise { - const current = this.scope.getSnapshot() - if (this.disposed || current.status !== 'ready' || !current.writable || this.saving) return - const desired = current.value?.enabled !== true - const generation = this.generation + private currentRoutes(): AllowedSubagentModel[] { + return this.scope.getSnapshot().value?.allowedModels?.map(route => ({ ...route })) ?? [] + } + + private selected(): Set { + return this.draftSelected ?? new Set(this.currentRoutes().map(subagentModelKey)) + } + + private enabled(): boolean { + return this.draftEnabled ?? this.currentRoutes().length > 0 + } + + private beginDraft(): Set { + this.draftEnabled ??= this.currentRoutes().length > 0 + this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey)) + return this.draftSelected + } + + private toggleEnabled(): void { + const snapshot = this.scope.getSnapshot() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return + this.beginDraft() + this.draftEnabled = !this.draftEnabled + this.saved = false + this.failed = false + if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog() + this.publish() + } + + private toggleModel(key: string): void { + if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return + if (!this.candidates().some(candidate => candidate.key === key)) return + const selected = this.beginDraft() + if (selected.has(key)) selected.delete(key) + else selected.add(key) + this.saved = false + this.failed = false + this.publish() + } + + private discard(): void { + if (this.saving) return + this.draftEnabled = undefined + this.draftSelected = undefined + this.saved = false + this.failed = false + this.publish() + } + + private candidates(): SubagentModelCandidate[] { + return subagentModelCandidates(this.catalogGroups, this.currentRoutes(), this.selected()) + } + + private desiredRoutes(): AllowedSubagentModel[] { + if (!this.enabled()) return [] + return this.candidates() + .filter(candidate => candidate.selected) + .map(({ provider, model }) => ({ provider, model })) + } + + private async save(): Promise { + const snapshot = this.scope.getSnapshot() + const desired = this.desiredRoutes() + if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving + || sameRoutes(this.currentRoutes(), desired) || (this.enabled() && desired.length === 0)) return + const generation = this.saveGeneration this.saving = true this.saved = false this.failed = false this.publish() - await this.scope.set('enabled', desired) - if (generation !== this.generation) return - const landed = this.scope.getSnapshot().value?.enabled === desired + await this.scope.set('allowedModels', desired) + if (generation !== this.saveGeneration) return + const landed = sameRoutes(this.currentRoutes(), desired) this.saving = false this.saved = landed this.failed = !landed + if (landed) { + this.draftEnabled = undefined + this.draftSelected = undefined + } + this.publish() + } + + private async loadCatalog(): Promise { + if (this.disposed || this.catalogStatus === 'loading') return + const generation = this.catalogGeneration + this.catalogStatus = 'loading' + this.catalogGroups = [] + this.catalogFailures = [] + this.publish() + try { + const response = await this.api.llm.models({}) + if (generation !== this.catalogGeneration) return + if (!response.result.ok) throw new Error(response.result.error.message) + this.catalogGroups = response.result.value.groups + this.catalogFailures = response.result.value.failures + this.catalogStatus = 'ready' + } catch { + if (generation !== this.catalogGeneration) return + this.catalogStatus = 'error' + } this.publish() } private projection(): SubagentModelSelectionCardState { const snapshot = this.scope.getSnapshot() + const current = this.currentRoutes() + const desired = this.desiredRoutes() + const enabled = this.enabled() return { available: snapshot.status === 'ready', writable: snapshot.writable, - enabled: snapshot.value?.enabled === true, + dirty: !sameRoutes(current, desired), + invalid: enabled && desired.length === 0, saving: this.saving, - saved: this.saved, failed: this.failed, + enabled, + candidates: this.candidates(), + catalogStatus: this.catalogStatus, + catalogFailures: this.catalogFailures, + saved: this.saved, } } diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 13c69a945f..46709d057f 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -84,22 +84,28 @@ function renderBash(state: Partial = {}) { function renderSubagentModelSelection(state: Partial = {}) { const store = createSnapshotStore({ - available: true, - writable: true, + ...settled, enabled: false, - saving: false, + candidates: [], + catalogStatus: 'idle', + catalogFailures: [], saved: false, - failed: false, ...state, }) - const toggle = vi.fn() + const actions = { + toggleEnabled: vi.fn(), + toggleModel: vi.fn(), + retryCatalog: vi.fn(), + save: vi.fn(), + discard: vi.fn(), + } const props = { + ...actions, t, - toggle, useSubagentModelSelectionCard: bindSnapshotSelector(store), } as unknown as SubagentModelSelectionCardProps render() - return toggle + return actions } describe('PluginsSettingsSection', () => { @@ -318,24 +324,75 @@ describe('BashCard', () => { }) describe('SubagentModelSelectionCard', () => { - it('renders the default-off preference directly in the Plugins list', () => { - const toggle = renderSubagentModelSelection() + it('renders the default-off preference in its staged plugin card', () => { + const actions = renderSubagentModelSelection() + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) const control = screen.getByRole('switch', { name: en.subagentModelSelectionToggle }) expect(control.getAttribute('aria-checked')).toBe('false') fireEvent.click(control) - expect(toggle).toHaveBeenCalledOnce() + expect(actions.toggleEnabled).toHaveBeenCalledOnce() }) - it('reports successful and rejected writes', () => { - renderSubagentModelSelection({ enabled: true, saved: true }) + it('renders adapter candidates and reports a successful save', () => { + const actions = renderSubagentModelSelection({ + enabled: true, + saved: true, + candidates: [{ + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }], + catalogStatus: 'ready', + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + }) + + it('renders directory progress, failures, unavailable routes, and validation', () => { + renderSubagentModelSelection({ enabled: true, catalogStatus: 'loading', invalid: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionLoading)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionRequired)).toBeTruthy() cleanup() - renderSubagentModelSelection({ failed: true }) - expect(screen.getByRole('alert').textContent).toBe(en.subagentModelSelectionSaveFailed) + const errorActions = renderSubagentModelSelection({ enabled: true, catalogStatus: 'error' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + fireEvent.click(screen.getByRole('button', { name: en.subagentModelSelectionRetry })) + expect(errorActions.retryCatalog).toHaveBeenCalledOnce() + + cleanup() + renderSubagentModelSelection({ + enabled: true, + catalogStatus: 'ready', + catalogFailures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + candidates: [{ + key: 'legacy\0old', + provider: 'legacy', + model: 'old', + providerName: 'legacy', + modelName: 'old', + available: false, + selected: true, + }], + }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + + cleanup() + renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() }) it('stays hidden when unavailable and disables writes when read-only', () => { @@ -343,11 +400,12 @@ describe('SubagentModelSelectionCard', () => { expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() cleanup() - const toggle = renderSubagentModelSelection({ writable: false }) + const actions = renderSubagentModelSelection({ writable: false }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) const control = screen.getByRole('switch') as HTMLButtonElement expect(control.disabled).toBe(true) fireEvent.click(control) - expect(toggle).not.toHaveBeenCalled() + expect(actions.toggleEnabled).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index a1554aa3c6..414ce8dab9 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -13,7 +13,9 @@ import { } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ConfigurablePluginsTabController } from '../src/client/tab-store.ts' import { - SubagentModelSelectionCardController, type SubagentModelSelectionSettings, + SubagentModelSelectionCardController, + subagentModelCandidates, + type SubagentModelSelectionSettings, } from '../src/client/subagent-model-selection-card-controller.ts' import { WebSearchCardController, type WebSearchSettings } from '../src/client/web-search-card-controller.ts' @@ -40,6 +42,34 @@ function credentialsApi(configured: boolean) { return { api: { describe, set } as never, describe, set } } +function modelsApi(options: { + groups?: readonly { + id: string + name: string + models: readonly { id: string; name: string }[] + }[] + failures?: readonly { id: string; name: string; message: string }[] + error?: string +} = {}) { + const models = vi.fn(() => Promise.resolve({ + rpcId: 'm-1' as never, + result: options.error === undefined + ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } + : { ok: false as const, error: { code: 'internal_error' as never, message: options.error } }, + })) + return { api: { llm: { models } } as never, models } +} + +function deferred() { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, resolve, reject } +} + describe('CardForm', () => { function form() { const host = stubSettingsScope>() @@ -387,19 +417,49 @@ describe('AgentLoopCardController', () => { }) describe('SubagentModelSelectionCardController', () => { - it('immediately writes a switch gesture and reports the accepted value', async () => { + it('joins stored routes with the live catalog without dropping unavailable choices', () => { + const candidates = subagentModelCandidates( + [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + [{ provider: 'legacy', model: 'old' }], + new Set(['legacy\0old']), + ) + + expect(candidates).toEqual([ + { + key: 'alpha\0fast', provider: 'alpha', model: 'fast', providerName: 'Alpha API', + modelName: 'Fast', available: true, selected: false, + }, + { + key: 'legacy\0old', provider: 'legacy', model: 'old', providerName: 'legacy', + modelName: 'old', available: false, selected: true, + }, + ]) + }) + + it('loads adapter models and saves one exact route as a whole field', async () => { const host = stubSettingsScope() acceptWrites(host) - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) const face = controller.inject() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) - face.toggle() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() + await vi.waitFor(() => { + expect(host.set).toHaveBeenCalledWith('allowedModels', [{ provider: 'alpha', model: 'fast' }]) + }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ enabled: true, + dirty: false, saving: false, saved: true, failed: false, @@ -408,61 +468,154 @@ describe('SubagentModelSelectionCardController', () => { it('keeps the Host value and reports a rejected write', async () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) const face = controller.inject() - face.toggle() + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + face.save() await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().failed).toBe(true) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - enabled: false, + enabled: true, + dirty: true, saving: false, saved: false, }) }) - it('ignores writes while read-only and scope notifications after disposal', () => { + it('loads stored routes, stages removal and disablement, and discards both', async () => { const host = stubSettingsScope() - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: false, value: { enabled: false }, user: {} }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, + value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) - face.toggle() - expect(host.set).not.toHaveBeenCalled() + face.toggleModel('missing') + expect(state().dirty).toBe(false) + face.toggleModel('alpha\0fast') + expect(state()).toMatchObject({ dirty: true, invalid: true }) + face.discard() + expect(state()).toMatchObject({ dirty: false, invalid: false, enabled: true }) - controller.dispose() - face.toggle() - host.publish({ value: { enabled: true } }) - expect(host.set).not.toHaveBeenCalled() - expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: true, enabled: false }) + face.toggleEnabled() + expect(state()).toMatchObject({ dirty: false, enabled: true }) }) - it('publishes no settlement after disposal interrupts an in-flight write', async () => { + it('reports a directory error and retries it', async () => { const host = stubSettingsScope() - let settle = (): void => {} - const pending = new Promise((resolve) => { settle = () => { resolve() } }) - host.set.mockReturnValue(pending) - const controller = new SubagentModelSelectionCardController(host.scope) - host.publish({ status: 'ready', writable: true, value: { enabled: false }, user: {} }) + const models = modelsApi({ error: 'offline' }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + + face.toggleEnabled() + await vi.waitFor(() => { expect(state().catalogStatus).toBe('error') }) + face.retryCatalog() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) }) + }) + + it('suppresses duplicate actions and late save settlements', async () => { + const host = stubSettingsScope() + const catalog = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const write = deferred() + const set = vi.fn(async (field: string, value: unknown) => { + await write.promise + host.publish({ value: { [field]: value } }) + }) + const controller = new SubagentModelSelectionCardController({ ...host.scope, set }, catalog.api) const face = controller.inject() - face.toggle() - await vi.waitFor(() => { expect(host.set).toHaveBeenCalledWith('enabled', true) }) + face.save() + face.toggleModel('alpha\0fast') + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + face.save() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') }) + face.save() + face.toggleModel('alpha\0fast') + face.save() expect(face.hooks.subagentModelSelectionCard.getSnapshot().saving).toBe(true) + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + face.discard() + controller.dispose() + write.resolve(undefined) + await write.promise + expect(set).toHaveBeenCalledOnce() + }) + + it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { + const host = stubSettingsScope() + host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + + const pending = deferred() + const models = vi.fn(() => pending.promise) + const controller = new SubagentModelSelectionCardController(host.scope, { llm: { models } } as never) + const face = controller.inject() + face.toggleEnabled() + face.retryCatalog() + expect(models).toHaveBeenCalledOnce() + controller.dispose() + pending.reject(new Error('late failure')) + await pending.promise.catch(() => undefined) + + const pendingResolve = deferred() + const resolving = new SubagentModelSelectionCardController( + host.scope, + { llm: { models: () => pendingResolve.promise } } as never, + ) + const resolvingFace = resolving.inject() + resolvingFace.toggleEnabled() + resolving.dispose() + pendingResolve.resolve({ + rpcId: 'late' as never, + result: { ok: true, value: { groups: [], failures: [] } }, + } as never) + await pendingResolve.promise + }) + + it('ignores writes while read-only and scope notifications after disposal', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: false, value: { allowedModels: [] }, user: {} }) + const face = controller.inject() + + face.toggleEnabled() + face.toggleModel('alpha\0fast') + face.save() + expect(host.set).not.toHaveBeenCalled() controller.dispose() - settle() - await Promise.resolve() - - expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - enabled: false, - saving: true, - saved: false, - failed: false, - }) + face.toggleEnabled() + face.retryCatalog() + face.save() + host.publish({ value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) + expect(host.set).not.toHaveBeenCalled() + expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) }) }) diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts index 3c2f004aab..f97c8d4846 100644 --- a/packages/core/session/src/known-event-types.ts +++ b/packages/core/session/src/known-event-types.ts @@ -49,7 +49,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ 'step/end', 'step/start', 'subagent/descriptor', - 'subagent/model-selection-enabled', + 'subagent/model-selection-policy', 'team/member', 'team/message/delivered', 'team/message/queued', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5412e025cd..abb2272981 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2086,10 +2086,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Singleton settings owner read by delegation tools when an Agent is published.', methods: [ { - signature: 'currentEnabled(): boolean', - description: 'Read the preference for the next eligible Agent publication.', + signature: 'currentAllowedModels(): AllowedModelRoute[]', + description: 'Read a detached route policy for the next eligible Agent publication.', parameters: [], - returns: 'whether that Agent should receive model-selectable delegation.', + returns: 'exact allowed routes; an empty list disables model-facing selection.', }, ], }, @@ -3362,6 +3362,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, + { + name: 'AllowedModelRoute', + declaration: 'export interface AllowedModelRoute {\n readonly provider: string;\n readonly model: string;\n}', + }, { name: 'ApiKeyRecord', declaration: 'export interface ApiKeyRecord {\n readonly kind: \'api-key\';\n readonly key?: string;\n readonly env?: Readonly>;\n}', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 603b9f6679..66f31e12f5 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: 5c3f7ae095c07ae579dadf103419b49dbf4794d4 -README.zh.md: e5276c913ae78fcb05f7d23162a943135c5d1666 +README.md: 72874e94b792ef3e75e4fa0d6ed7475808f209a0 +README.zh.md: 1cab6922b93c0fd8776158f1659927d0c4aa4126 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5c3f7ae095..72874e94b7 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -146,7 +146,7 @@ Prefix-stable while provider instances and their configuration are unchanged. Ad #### What the model sees -An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session decision is enabled, exposes child LLM selection fields and `list_subagent_models`. With no arguments the discovery tool returns registered provider ids and names; with `provider` it returns advertised models; with `provider` and `model` it resolves that model and returns its advertised reasoning efforts and default. Calls reject while the optional `ctx.llm` service is unavailable. The result is read-only runtime metadata, not an authorization list. +An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session policy is non-empty, exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Static enablement returns the live adapter directory. A settings-controlled instance returns only registered providers and advertised models in its exact route policy; an exact lookup must also be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy. #### Token effect diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index e5276c913a..1cab6922b9 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -146,7 +146,7 @@ kind: "package-reference" #### 模型看到什么 -静态设置 `enableModelSelection: true` 的实例,或其 Session 决定为启用的设置控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。不带参数时,发现工具返回已注册提供方的 id 与名称;带 `provider` 时返回其公布模型;同时带 `provider` 与 `model` 时解析该模型,并返回其公布的推理等级与默认值。可选的 `ctx.llm` 服务不可用时,调用会失败。结果是只读运行时元数据,不是授权清单。 +静态配置 `enableModelSelection: true` 的实例,或 Session 策略非空的 settings 控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。静态启用返回实时适配器目录。settings 控制实例只返回其精确路由策略中的已注册提供方与已公布模型;精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。 #### Token 影响 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index f4737cbb3b..5304d0bcc7 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -25,17 +25,18 @@ import type { SubagentProvider, SubagentResult, SubagentRun } from '@deepseek-ai import type { JobOutcome } from '@deepseek-ai/dsh-jobs' import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { + assertAllowedModelSelection, hasConfiguredLlmSelection, hasDelegationModelRequest, preflightChildLlmRoute, requestedAgentOptions, } from './model-selection.ts' -import type { DelegationModelRequest } from './model-selection.ts' +import type { DelegationModelRequest, ModelSelectionPolicy } from './model-selection.ts' import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' import { - hasSubagentModelSelection, recordSubagentModelSelection, + subagentModelSelectionPolicy, } from './model-selection-state.ts' export const name = 'tool-subagent' @@ -357,8 +358,9 @@ export function apply(ctx: Context, config: Config): void { const initialProvider = ctx.subagents.getProvider(config.provider) if (initialProvider !== undefined) assertSubagentProviderConfiguration(initialProvider) - const install = (runtimeCtx: Context, modelSelectionEnabled: boolean): void => { - if (modelSelectionEnabled) registerListSubagentModels(runtimeCtx) + const install = (runtimeCtx: Context, modelSelectionPolicy: ModelSelectionPolicy | undefined): void => { + const modelSelectionEnabled = modelSelectionPolicy !== undefined + if (modelSelectionPolicy !== undefined) registerListSubagentModels(runtimeCtx, modelSelectionPolicy) // Load order and HMR replacement can change provider availability while // this fiber remains active. let mounted: { subagentProvider: SubagentProvider; disposeTool: () => void } | undefined @@ -487,6 +489,12 @@ export function apply(ctx: Context, config: Config): void { modelRequest, modelSelectionEnabled, ) + assertAllowedModelSelection( + modelSelectionPolicy, + parentOptions, + requestedChildAgentOptions, + modelRequest, + ) if (requiresRoutePreflight) { const llm = runtimeCtx.get('llm') if (llm === undefined) { @@ -599,7 +607,7 @@ export function apply(ctx: Context, config: Config): void { } if (config.modelSelectionSettings !== true) { - install(ctx, config.enableModelSelection === true) + install(ctx, config.enableModelSelection === true ? { kind: 'unrestricted' } : undefined) return } @@ -615,21 +623,22 @@ export function apply(ctx: Context, config: Config): void { throw new Error('tool-subagent: `modelSelectionSettings` requires an Agent or preset scope') } - const selectForAgent = (agent: NonNullable): boolean => { - let enabled = hasSubagentModelSelection(agent.session) - if (!enabled) { + const selectForAgent = (agent: NonNullable): ModelSelectionPolicy | undefined => { + let allowedModels = subagentModelSelectionPolicy(agent.session) + if (allowedModels === undefined) { const parentId = agent.session.header.origin === 'subagent' ? agent.session.header.parentSession : undefined if (parentId !== undefined) { const parent = ctx.get('agents')?.get(parentId) - enabled = parent !== undefined && hasSubagentModelSelection(parent.session) + allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(parent.session) } else if (agent.session.firstLiveSeq === 0) { - enabled = settings.currentEnabled() + const current = settings.currentAllowedModels() + allowedModels = current.length === 0 ? undefined : current } } - if (enabled) recordSubagentModelSelection(agent.session) - return enabled + if (allowedModels !== undefined) recordSubagentModelSelection(agent.session, allowedModels) + return allowedModels === undefined ? undefined : { kind: 'allowlist', routes: allowedModels } } const agent = ctx.agent @@ -649,9 +658,9 @@ export function apply(ctx: Context, config: Config): void { // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) - const enabled = selectForAgent(candidate) + const policy = selectForAgent(candidate) const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { - install(runtimeCtx, enabled) + install(runtimeCtx, policy) }) installing.delete(candidate) scopedInstalls.set(candidate, fiber) diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 84bd209caa..9207e01e4f 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -6,7 +6,7 @@ /* jscpd:ignore-start */ import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import { hasSubagentModelSelection } from './model-selection-state.ts' +import { subagentModelSelectionPolicy } from './model-selection-state.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' @@ -18,7 +18,7 @@ export const inject = ['invariants'] /** Assert that a durable opt-in is represented by both model-facing definitions. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('agent/pre-step', async ({ agent }, next) => { - if (hasSubagentModelSelection(agent.session)) { + if (subagentModelSelectionPolicy(agent.session) !== undefined) { const schemas = ctx.tools.schemas(agent) const selectable = schemas.some((schema) => { const properties = (schema.parameters as { properties?: Record }).properties @@ -27,7 +27,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant && properties['reasoning_effort'] !== undefined }) if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) { - fail('a subagent/model-selection-enabled session must expose route fields and list_subagent_models') + fail('a subagent/model-selection-policy session must expose route fields and list_subagent_models') } } return next() diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts index 9e1ff5c24e..61158ca103 100644 --- a/packages/subagent/tool-subagent/src/list-models.ts +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis' import type LlmRuntime from '@deepseek-ai/dsh-llm' import type { LlmProviderInfo } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' +import type { ModelSelectionPolicy } from './model-selection.ts' interface ListSubagentModelsRequest { readonly provider?: string @@ -27,6 +28,7 @@ function modelLine(provider: string, model: { id: string; name: string; descript /** Read the requested provider, advertised models, or exact-model efforts. */ async function listSubagentModels( ctx: Context, + policy: ModelSelectionPolicy, request: ListSubagentModelsRequest, signal: AbortSignal, ): Promise { @@ -38,7 +40,8 @@ async function listSubagentModels( throw new Error('`model` requires `provider`') } if (request.provider === undefined) { - const providers = llm.listProviders() + const providers = llm.listProviders().filter(provider => policy.kind === 'unrestricted' + || policy.routes.some(route => route.provider === provider.id)) return providers.length === 0 ? '(no LLM providers)' : providers.map(provider => `${provider.id} — ${provider.name}`).join('\n') @@ -46,12 +49,17 @@ async function listSubagentModels( if (request.provider.length === 0) throw new Error('`provider` must be non-empty') const provider = registeredProvider(llm, request.provider) if (request.model === undefined) { - const models = await llm.listModels(provider.id) + const models = (await llm.listModels(provider.id)).filter(model => policy.kind === 'unrestricted' + || policy.routes.some(route => route.provider === provider.id && route.model === model.id)) return models.length === 0 ? `(no advertised models for ${provider.id})` : models.map(model => modelLine(provider.id, model)).join('\n') } if (request.model.length === 0) throw new Error('`model` must be non-empty') + if (policy.kind === 'allowlist' + && !policy.routes.some(route => route.provider === provider.id && route.model === request.model)) { + throw new Error(`child LLM route "${provider.id}/${request.model}" is not allowed for this Session`) + } const model = await llm.resolveModelInfo(provider.id, request.model, signal) const efforts = model.reasoning?.efforts.map(effort => ( `${effort.id}${model.reasoning?.defaultEffort === effort.id ? ' (default)' : ''} — ${effort.name}` @@ -63,8 +71,9 @@ async function listSubagentModels( /** * Register `list_subagent_models` for one owning delegation-tool instance. * @param ctx - Context whose tool registry owns the fixed discovery definition. + * @param policy - Route policy captured for this Session. */ -export function registerListSubagentModels(ctx: Context): void { +export function registerListSubagentModels(ctx: Context, policy: ModelSelectionPolicy): void { ctx.tools.register(defineTool({ name: 'list_subagent_models', description: @@ -88,7 +97,7 @@ export function registerListSubagentModels(ctx: Context): void { render: (_args, result) => [{ type: 'text', text: result }], }, execute(args, exec) { - return listSubagentModels(ctx, args, exec.signal) + return listSubagentModels(ctx, policy, args, exec.signal) }, })) } diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index 113cd4c6f8..f12aae331c 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -3,6 +3,11 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' +import { + AllowedModelRouteSchema, + assertAllowedModelRoutes, + type AllowedModelRoute, +} from './model-selection.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -16,32 +21,35 @@ export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = settingsNamespace('su /** Stored user preference; the shipped composition defaults it off. */ export interface SubagentModelSelectionSettings { - /** Whether new Agents may expose child LLM route selection to the model. */ - enabled: boolean + /** Exact child LLM routes offered to newly composed top-level Sessions. */ + allowedModels: AllowedModelRoute[] } /** Schema served to settings clients for the opt-in preference. */ export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ - enabled: z.boolean().default(false), + allowedModels: z.array(AllowedModelRouteSchema).default([]), }) /** Optional deployment base for the preference. */ export interface Config { - /** Initial value inherited when the user document does not override it. */ - enabled?: boolean + /** Initial route list inherited when the user document does not override it. */ + allowedModels?: AllowedModelRoute[] } /** Singleton settings owner read by delegation tools when an Agent is published. */ export class SubagentModelSelectionConfig extends Service { static Config: z = z.object({ - enabled: z.boolean().default(false), + allowedModels: z.array(AllowedModelRouteSchema).default([]), }) private source: () => SubagentModelSelectionSettings constructor(ctx: Context, config: Config = {}) { super(ctx, 'subagentModelSelection') - const entry: SubagentModelSelectionSettings = { enabled: config.enabled === true } + // Cordis supplies the schema default; the fallback also covers direct construction. + /* v8 ignore next */ + const entry: SubagentModelSelectionSettings = { allowedModels: config.allowedModels ?? [] } + assertAllowedModelRoutes(entry.allowedModels) this.source = () => entry installSettingsSection( ctx, @@ -50,6 +58,7 @@ export class SubagentModelSelectionConfig extends Service { entry, { setSource: (source) => { this.source = source }, + validate: (value) => { assertAllowedModelRoutes(value.allowedModels) }, // Consumers sample at Agent publication, so a settings update never // rebuilds the tool definitions of an Agent that is already running. onChange: () => {}, @@ -58,11 +67,11 @@ export class SubagentModelSelectionConfig extends Service { } /** - * Read the preference for the next eligible Agent publication. - * @returns whether that Agent should receive model-selectable delegation. + * Read a detached route policy for the next eligible Agent publication. + * @returns exact allowed routes; an empty list disables model-facing selection. */ - currentEnabled(): boolean { - return this.source().enabled + currentAllowedModels(): AllowedModelRoute[] { + return this.source().allowedModels.map(route => ({ ...route })) } } diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index 35345ac115..b729d6d10b 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -1,6 +1,7 @@ /** Durable per-session state for the user-controlled model-selection opt-in. */ import type { Session } from '@deepseek-ai/dsh-session' +import { assertAllowedModelRoutes, type AllowedModelRoute } from './model-selection.ts' declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { @@ -10,24 +11,35 @@ declare module '@deepseek-ai/dsh-session/types' { * request; absence means the fixed-route definition. Log-only: it carries * no `surfaceOp` and never enters model history. */ - 'subagent/model-selection-enabled': Record + 'subagent/model-selection-policy': { + /** Exact routes this Session may select explicitly for a child. */ + allowedModels: AllowedModelRoute[] + } } } /** - * Whether a session log records the enabled model-selection definition. + * Read the exact route list captured for a model-selectable definition. * @param session - session whose durable decision is read. - * @returns whether model-selectable delegation is enabled for the session. + * @returns a detached route list, or undefined for the fixed-route definition. */ -export function hasSubagentModelSelection(session: Session): boolean { - return session.events.some(event => event.type === 'subagent/model-selection-enabled') +export function subagentModelSelectionPolicy(session: Session): AllowedModelRoute[] | undefined { + const event = session.events.find(candidate => candidate.type === 'subagent/model-selection-policy') + if (event?.type !== 'subagent/model-selection-policy') return undefined + const routes = event.data.allowedModels.map(route => ({ ...route })) + assertAllowedModelRoutes(routes) + if (routes.length === 0) throw new Error('subagent/model-selection-policy requires at least one route') + return routes } /** - * Append the enabled decision once, before its definition can reach a model request. - * @param session - session receiving the enabled decision. + * Append the route policy once, before its definition can reach a model request. + * @param session - session receiving the model-selectable definition. + * @param allowedModels - exact routes the definition may select explicitly. */ -export function recordSubagentModelSelection(session: Session): void { - if (hasSubagentModelSelection(session)) return - session.append('subagent/model-selection-enabled', {}) +export function recordSubagentModelSelection(session: Session, allowedModels: readonly AllowedModelRoute[]): void { + if (subagentModelSelectionPolicy(session) !== undefined) return + session.append('subagent/model-selection-policy', { + allowedModels: allowedModels.map(route => ({ ...route })), + }) } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index 906eb496d5..df8251690d 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -3,6 +3,53 @@ import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { LlmRuntime } from '@deepseek-ai/dsh-llm' import type { AgentOptions } from '@deepseek-ai/dsh-agent' +import z from '@deepseek-ai/schemastery' + +/** One exact child LLM route authorized by a user setting. */ +export interface AllowedModelRoute { + /** Registered LLM provider id. */ + readonly provider: string + /** Provider-owned exact model id. */ + readonly model: string +} + +/** Schema shared by the Host setting and its deployment base. */ +export const AllowedModelRouteSchema: z = z.object({ + provider: z.string().min(1).required(), + model: z.string().min(1).required(), +}) + +/** Route-selection authority captured by one delegation definition. */ +export type ModelSelectionPolicy = + | { readonly kind: 'unrestricted' } + | { readonly kind: 'allowlist'; readonly routes: readonly AllowedModelRoute[] } + +/** + * Stable identity for one provider/model pair. + * @param route - Exact provider/model route. + * @returns Opaque key for equality checks. + */ +export function modelRouteKey(route: AllowedModelRoute): string { + return `${route.provider}\0${route.model}` +} + +/** + * Reject malformed or duplicate route policy entries at a configuration boundary. + * @param routes - Exact routes to validate. + */ +export function assertAllowedModelRoutes(routes: readonly AllowedModelRoute[]): void { + const seen = new Set() + for (const route of routes) { + if (route.provider.length === 0 || route.model.length === 0) { + throw new Error('subagent model selection requires non-empty provider and model ids') + } + const key = modelRouteKey(route) + if (seen.has(key)) { + throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`) + } + seen.add(key) + } +} /** Model-facing child LLM route fields. */ export interface DelegationModelRequest { @@ -70,6 +117,31 @@ export function requestedAgentOptions( } } +/** + * Enforce a settings-owned route list at the operation that creates the child. + * Pure inheritance remains outside this policy because no model-facing choice + * occurred; any explicit route or effort field must resolve to an allowed route. + * @param policy - Selection authority captured for this Session. + * @param parentOptions - Current parent values that supply missing child values. + * @param requested - Effective child options after request/config merging. + * @param request - Model-facing selection fields from the tool call. + */ +export function assertAllowedModelSelection( + policy: ModelSelectionPolicy | undefined, + parentOptions: AgentOptions, + requested: AgentOptions | undefined, + request: DelegationModelRequest, +): void { + if (policy?.kind !== 'allowlist' || !hasDelegationModelRequest(request)) return + const provider = requested?.provider ?? parentOptions.provider + const model = requested?.model ?? parentOptions.model + if (provider === undefined || model === undefined) { + throw new Error('cannot select child LLM values without an effective provider and model') + } + if (policy.routes.some(route => route.provider === provider && route.model === model)) return + throw new Error(`child LLM route "${provider}/${model}" is not allowed for this Session`) +} + /** * Whether configured Agent options require route validation before delegation. * @param options - Tool-instance child defaults. diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index 893f11a9ff..c58c775946 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -15,6 +15,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import * as tool from '../src/index.ts' +import { registerListSubagentModels } from '../src/list-models.ts' import { testToolSignal, text } from './harness.ts' class CatalogAdapter extends LlmAdapter { @@ -66,6 +67,22 @@ async function setupListTool() { return { ctx, fiber } } +async function setupAllowedListTool() { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRuntime) + registerListSubagentModels(ctx, { + kind: 'allowlist', + routes: [ + { provider: 'alpha', model: 'fast' }, + { provider: 'alpha', model: 'unlisted' }, + { provider: 'missing', model: 'hidden' }, + ], + }) + return ctx +} + let counter = 0 function call(ctx: Context, args: unknown) { @@ -135,6 +152,20 @@ describe('list_subagent_models', () => { expect(text(result)).toBe('alpha/fast — Fast: Focused work.\nalpha/plain — Plain') }) + it('intersects provider and model discovery with the Session allowlist', async () => { + const ctx = await setupAllowedListTool() + ctx.llm.registerAdapter(['alpha', 'beta'], new CatalogAdapter()) + + expect(text(await call(ctx, {}))).toBe('alpha — ALPHA API') + expect(text(await call(ctx, { provider: 'alpha' }))).toBe('alpha/fast — Fast: Focused work.') + expect(text(await call(ctx, { provider: 'alpha', model: 'unlisted' }))) + .toContain('alpha/unlisted — Fast') + + const denied = await call(ctx, { provider: 'alpha', model: 'plain' }) + expect(denied.isError).toBe(true) + expect(text(denied)).toContain('is not allowed for this Session') + }) + it('renders an empty advertised model list', async () => { const { ctx } = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true)) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4f95db088f..7cd0926bdb 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' @@ -17,7 +18,10 @@ import * as ToolInvariant from '../src/invariant.ts' import SubagentModelSelectionConfig, { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, } from '../src/model-selection-settings.ts' -import { hasSubagentModelSelection } from '../src/model-selection-state.ts' +import { subagentModelSelectionPolicy } from '../src/model-selection-state.ts' +import { text } from './harness.ts' + +const ALLOWED_MODELS = [{ provider: 'alpha', model: 'fast-model' }] /** Writable in-memory settings provider for the package integration. */ class MemorySettings extends SettingsProvider { @@ -81,9 +85,9 @@ async function createAgent(ctx: Context, id: string, options: { describe('SubagentModelSelectionConfig', () => { it('uses the composed default without a settings provider', async () => { const ctx = new Context() - await ctx.plugin(SubagentModelSelectionConfig, { enabled: true }) + await ctx.plugin(SubagentModelSelectionConfig, { allowedModels: ALLOWED_MODELS }) - expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) await ctx.fiber.dispose() }) @@ -92,9 +96,24 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) - expect(ctx.subagentModelSelection.currentEnabled()).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) - expect(ctx.subagentModelSelection.currentEnabled()).toBe(true) + expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual([]) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) + await ctx.fiber.dispose() + }) + + it('rejects duplicate routes and an empty durable policy', async () => { + const ctx = new Context() + await ctx.plugin(MemorySettings) + await ctx.plugin(SubagentModelSelectionConfig) + + await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS], + })).rejects.toThrow('repeats route "alpha/fast-model"') + + const invalid = Session.create(SessionId('empty-policy')) + invalid.append('subagent/model-selection-policy', { allowedModels: [] }) + expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route') await ctx.fiber.dispose() }) @@ -102,21 +121,44 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) - expect(hasSubagentModelSelection(disabled.session)).toBe(false) + expect(subagentModelSelectionPolicy(disabled.session)).toBeUndefined() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const enabled = await createAgent(ctx, 'enabled') - expect(hasSubagentModelSelection(enabled.session)).toBe(true) + expect(subagentModelSelectionPolicy(enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) const disabledAgain = await createAgent(ctx, 'disabled-again') expect(selectable(ctx, disabledAgain)).toBe(false) expect(selectable(ctx, enabled)).toBe(true) await ctx.fiber.dispose() }) + it('rejects a forced route outside the Session policy before child creation', async () => { + const ctx = await boot() + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + const agent = await createAgent(ctx, 'enforced') + + const result = await ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('disallowed-session-route'), + name: 'subagent', + arguments: { + description: 'forced route', + prompt: 'do it', + provider: 'alpha', + model: 'other-model', + }, + agent, + }) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('is not allowed for this Session') + await ctx.fiber.dispose() + }) + it('installs per-Agent definitions for a shared preset scope', async () => { const ctx = await boot() const preset = createScope(ctx, { preset: 'standard' }) @@ -138,7 +180,7 @@ describe('SubagentModelSelectionConfig', () => { const disabled = await createComposed('preset-disabled') expect(selectable(ctx, disabled.agent)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const enabled = await createComposed('preset-enabled') expect(selectable(ctx, enabled.agent)).toBe(true) expect(selectable(ctx, disabled.agent)).toBe(false) @@ -158,25 +200,30 @@ describe('SubagentModelSelectionConfig', () => { it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const parent = await createAgent(ctx, 'parent') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) const child = await createAgent(ctx, 'child', { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(hasSubagentModelSelection(child.session)).toBe(true) + expect(subagentModelSelectionPolicy(child.session)).toEqual(ALLOWED_MODELS) + + const orphan = await createAgent(ctx, 'orphan', { + meta: { parentSession: SessionId('missing-parent'), origin: 'subagent' }, + }) + expect(selectable(ctx, orphan)).toBe(false) const enabledSeed = Session.create(SessionId('enabled-seed')) - enabledSeed.append('subagent/model-selection-enabled', {}) + enabledSeed.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) const resumedEnabled = await createAgent(ctx, 'resumed-enabled', { seed: enabledSeed.events }) expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) expect(selectable(ctx, resumedDisabled)).toBe(false) - expect(hasSubagentModelSelection(resumedDisabled.session)).toBe(false) + expect(subagentModelSelectionPolicy(resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) @@ -235,11 +282,11 @@ describe('SubagentModelSelectionConfig', () => { kind: 'enter', messages: [], }) - disabled.session.append('subagent/model-selection-enabled', {}) + disabled.session.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .rejects.toThrow('must expose route fields and list_subagent_models') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) const enabled = await createAgent(ctx, 'invariant-enabled') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 008ba66da4..710747bacc 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -10,6 +10,7 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' +import { assertAllowedModelRoutes, assertAllowedModelSelection } from '../src/model-selection.ts' import { callSubagent, setup, text } from './harness.ts' const REASONING = { @@ -32,6 +33,54 @@ function parentWithRoute( } describe('dsh-tool-subagent model selection', () => { + it('rejects empty route ids at the configuration boundary', () => { + expect(() => { assertAllowedModelRoutes([{ provider: '', model: 'model' }]) }) + .toThrow('requires non-empty provider and model ids') + expect(() => { assertAllowedModelRoutes([{ provider: 'provider', model: '' }]) }) + .toThrow('requires non-empty provider and model ids') + }) + + it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => { + const policy = { + kind: 'allowlist' as const, + routes: [{ provider: 'alpha', model: 'allowed-model' }], + } + const parent = { provider: 'alpha', model: 'parent-model' } + + expect(() => { assertAllowedModelSelection(policy, parent, undefined, {}) }).not.toThrow() + expect(() => { + assertAllowedModelSelection( + policy, + parent, + { provider: 'alpha', model: 'allowed-model' }, + { provider: 'alpha', model: 'allowed-model' }, + ) + }).not.toThrow() + expect(() => { + assertAllowedModelSelection( + policy, + parent, + { provider: 'alpha', model: 'other-model' }, + { provider: 'alpha', model: 'other-model' }, + ) + }).toThrow('is not allowed for this Session') + expect(() => { + assertAllowedModelSelection( + policy, + parent, + { reasoningEffort: ReasoningEffortId('low') }, + { reasoning_effort: 'low' }, + ) + }).toThrow('alpha/parent-model') + expect(() => { + assertAllowedModelSelection( + policy, + {}, + { reasoningEffort: ReasoningEffortId('low') }, + { reasoning_effort: 'low' }, + ) + }).toThrow('without an effective provider and model') + }) it('exposes static route fields and discovery when selection is enabled', async () => { const ctx = await setup({ provider: 'mock', enableModelSelection: true }) const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 3332b24350..7194655904 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -240,6 +240,7 @@ export const LINK_MAP: Readonly> = { AgentFactory: 'core.md', AgentHandle: 'core.md', ModelSelection: 'core.md', + AllowedModelRoute: 'subagent.md', AgentOptions: 'core.md', AgentStatus: 'core.md', ContentBlock: 'llm-streaming.md', From ebe8d4db1cf57c5640cd0045626e487c226d3ffe Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 00:42:08 +0800 Subject: [PATCH 032/130] test(web): configure subagent model allowlist --- apps/cli/tests/web-agent-presets.e2e.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 4747543ec0..bb2998d51b 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -241,13 +241,15 @@ describe('the shipped Web composition', () => { } }) - it('applies the default-off subagent model-selection preference only to new sessions', async () => { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + it('applies the default-off subagent model allowlist only to new sessions', async () => { + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) const disabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-disabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }], + }) const enabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-enabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), @@ -263,7 +265,7 @@ describe('the shipped Web composition', () => { ])) expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') } finally { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) await enabled.dispose() await disabled.dispose() } From 7c626fb5d2a5ff32542c3bee5b64642d21856b90 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 15:13:30 +0800 Subject: [PATCH 033/130] fix(subagent): gate model selection with explicit allowlist --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 8 +- ...08-18-model-selected-subagent-routes.zh.md | 8 +- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 8 +- ...ser-authorized-subagent-model-routes.zh.md | 8 +- apps/cli/tests/profiles/headless/cordis.yml | 1 - apps/cli/tests/web-agent-presets.e2e.ts | 8 +- apps/web/tests/plugin-config.e2e.ts | 10 +++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 5 +- docs/config-catalog.zh.md | 5 +- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 6 +- docs/subsystems/subagent.zh.md | 6 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 18 +--- docs/tool-catalog.zh.md | 18 +--- packages/bundle/base/cordis.patch.yml | 1 - ...ubagent-model-selection-card-controller.ts | 33 ++++--- .../tests/stores.client.spec.ts | 80 +++++++++++++---- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 5 +- packages/client/ui-settings/README.zh.md | 5 +- .../src/client/settings-contract.ts | 12 ++- .../ui-settings/src/client/settings-scope.ts | 14 ++- .../tests/settings-scope.client.spec.ts | 29 +++++- .../extensions/tool-cordis/src/api-catalog.ts | 10 +-- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 13 ++- packages/subagent/tool-subagent/README.zh.md | 13 ++- packages/subagent/tool-subagent/src/index.ts | 19 ++-- .../subagent/tool-subagent/src/list-models.ts | 28 ++++-- .../src/model-selection-settings.ts | 34 +++++-- .../tool-subagent/src/model-selection.ts | 9 +- .../subagent/tool-subagent/tests/harness.ts | 50 ++++++++++- .../tool-subagent/tests/list-models.spec.ts | 69 ++++++++------ .../tests/model-selection-settings.spec.ts | 75 ++++++++++------ .../tests/model-selection.spec.ts | 90 +++++++++++-------- .../tool-subagent/tests/tool-subagent.spec.ts | 7 +- .../client-runtime/src/settings-scope.ts | 5 ++ scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 6 +- .../cancel-tool-calls/stdout.expected.jsonl | 7 -- snapshots/acp/escalation-approved/cordis.yml | 1 - .../tool-schemas.1.expected.json | 31 +------ .../tool-schemas.1.expected.json | 31 +------ .../tool-schemas.1.expected.json | 31 +------ .../tool-schemas.1.expected.json | 31 +------ .../both-mode-turn/system-prompt.expected.md | 16 +--- .../both-mode-turn/tool-schemas.expected.json | 31 +------ .../system-prompt.expected.md | 16 +--- .../code-mode-turn/system-prompt.expected.md | 16 +--- .../system-prompt.expected.md | 16 +--- .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../lsp-definition/tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../tool-schemas.expected.json | 31 +------ .../cordis.snapshot.yml | 66 -------------- .../cordis.yml | 15 ---- .../replay.override.json | 32 ------- .../session.jsonl | 41 --------- .../cordis.snapshot.yml | 1 - .../subagent-depth-two-rejection/cordis.yml | 1 - .../text-turn/tool-schemas.expected.json | 31 +------ .../web-fetch/tool-schemas.expected.json | 31 +------ 71 files changed, 498 insertions(+), 971 deletions(-) delete mode 100644 snapshots/acp/cancel-tool-calls/stdout.expected.jsonl delete mode 100644 snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml delete mode 100644 snapshots/session/subagent-configured-effort-rejection/cordis.yml delete mode 100644 snapshots/session/subagent-configured-effort-rejection/replay.override.json delete mode 100644 snapshots/session/subagent-configured-effort-rejection/session.jsonl diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index eed424a054..878ed45e35 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: 9c6d777a1dbed569340e4900559a2cee2a42ff82 -2026-08-18-model-selected-subagent-routes.zh.md: a6d665f9533b481edf2f91071f7561544744d202 +2026-08-18-model-selected-subagent-routes.md: 768da30d26f7daede6ed68dda72efcb4207ab60f +2026-08-18-model-selected-subagent-routes.zh.md: 9cf3033af0e143589ff6806acbb4600478e83929 diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index 9c6d777a1d..768da30d26 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -12,15 +12,15 @@ The model also needs a bounded way to discover live providers and model-owned ef ## Decision -`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its instance enables `enableModelSelection`, or its Agent-scoped `modelSelectionSettings` instance resolves a non-empty Session policy, and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. Static enablement needs no route list and can select any route its adapter accepts. The shipped settings-controlled path uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md). Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. Either selection mode against a provider without the capability fails the plugin mount. +`dsh-tool-subagent` exposes optional `provider`, `model`, and `reasoning_effort` fields only when its Agent-scoped `modelSelectionSettings` instance resolves a Session policy and the bound subagent provider advertises `SubagentCapabilities.agentOptions`. The policy uses the exact user authorization owned by [user-authorized subagent model routes](2026-08-24-user-authorized-subagent-model-routes.md); there is no unrestricted static mode. Disabled instances omit and reject model-facing selection, while configured `Config.agentOptions` remain deployment-owned defaults. A settings-enabled instance against a provider without the capability fails the plugin mount. Provider and model form one route and must be supplied together. An effort may be supplied alone when configured, parent, or provider-owned route defaults provide the effective route. Static `provider.agentRouteDefaults`, when present, establish the provider/model baseline; `Config.agentOptions` and model arguments overlay it before route-aware effort clearing. Providers without static defaults use compatible fields from the parent Agent's latest logged request selection, with creation options supplying the fallback before its first request and retaining the configured output-token limit. Reasoning-effort identifiers remain adapter-owned. An unchanged route inherits an omitted effort only from the selected baseline; changing provider or model without naming an effort clears the lower layer's route-owned value so the selected model resolves its own default. `AgentOptions` carries the resulting effort into the child loop, whose request header logs the effective value. A continuable descriptor records it with the resolved provider and model so a child that has not logged its first request can cold-resume with the same selection. An explicit or configured provider, model, or effort resolves through `ctx.llm.resolveCallConfig()` after the provider baseline and request precedence are complete. Providers with static route defaults suppress parent-effort inheritance when the request omits effort, preserving the selected model's default. The LLM lookup owns provider registration, exact-model metadata, reasoning-effort validation, and adapter defaults. After the asynchronous lookup, the tool checks cancellation and confirms the same provider instance remains registered before creating a child or background job, so HMR cannot combine one provider's defaults with another provider's process. Calls with no model-facing selection and no configured route fields preserve the existing provider path without requiring the optional LLM service. -An enabled definition registers `list_subagent_models`. With no arguments the tool lists registered providers; with `provider` it calls that adapter's advisory model catalog; with `provider` and `model` it resolves the exact model and returns its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Static enablement exposes the live directory without another filter. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-empty Host-owned `subagent-model-selection.allowedModels` setting. The Plugins settings page stores exact provider/model routes from the adapter directory. A new top-level Session snapshots a non-empty policy as `subagent/model-selection-policy` before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. In settings-controlled Sessions, discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. +An enabled definition registers `list_subagent_models`. With no arguments the tool lists authorized registered providers; with `provider` it calls that adapter's advisory model catalog only after authorization; with `provider` and `model` it authorizes the exact route before resolving its reasoning efforts and default. At most one instance in a tool scope enables selection because the discovery name is global. Shipped product compositions put `modelSelectionSettings: true` on the primary Agent-scoped `subagent` instance and register the default-off Host-owned `subagent-model-selection` setting with an explicit `enabled` switch and `allowedModels` list. The Plugins settings page stores both fields atomically. A new top-level Session snapshots the route policy as `subagent/model-selection-policy` when the setting is enabled, before any model request. A child Session inherits the live parent's policy, and a resumed Session uses its recorded event instead of current settings. Therefore a settings edit affects only subsequently composed top-level Sessions. The fixed discovery definition remains available without the optional LLM service, while discovery and selected-route calls fail until that service is present. Discovery lists the intersection of the live catalog and recorded policy, and the executor rejects explicit routes outside it. -Shipped `subagent_fork` instances leave `enableModelSelection` disabled even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. +Shipped `subagent_fork` instances do not read model-selection settings even though the in-process fork provider supports `agentOptions`. A fork inherits the parent's effective provider and model so its copied conversation prefix remains eligible for provider-side KV Cache reuse. Changing either route component requires the new route to prefill that inherited history again, and that recomputation can dominate the delegated task's cost. This restriction is independent of the discovery tool's global name: separating discovery ownership would permit the configuration but would not preserve reuse. Fork route selection remains unavailable until a route change can retain prefix reuse or the caller can explicitly bound and accept the recomputation cost. The delegation definition is static across adapter registration and catalog changes, so live topology neither expands every parent request nor invalidates its cache prefix. The discovery result enters the transcript only when called. A custom inheritance-capable instance that enables selection warns that changing provider or model can prevent provider-side reuse of the inherited conversation prefix. @@ -49,7 +49,7 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences - A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. -- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy is non-empty; discovery and explicit selection are constrained to that policy. +- The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. - Adapter catalog and topology changes leave the delegation definition and its prompt-cache prefix unchanged. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index a6d665f953..9cf3033af0 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -12,15 +12,15 @@ Status: implemented ## 决策 -只有实例启用 `enableModelSelection`,或其 Agent 作用域的 `modelSelectionSettings` 实例解析出非空 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。静态启用无需路由列表,并且可以选择适配器接受的任意路由。随附的 settings 控制路径使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果提供方缺少该能力,任一种选择模式都会使插件挂载失败。 +只有 Agent 作用域的 `modelSelectionSettings` 实例解析出 Session 策略,且绑定的 subagent 提供方声明 `SubagentCapabilities.agentOptions` 时,`dsh-tool-subagent` 才公开可选的 `provider`、`model` 与 `reasoning_effort` 字段。该策略使用[用户授权的 subagent 模型路由](2026-08-24-user-authorized-subagent-model-routes.zh.md)所拥有的精确用户授权;不存在无限制静态模式。禁用的实例会省略并拒绝面向模型的选择,而配置的 `Config.agentOptions` 仍是部署方所有的默认值。如果 settings 已启用的实例缺少该提供方能力,插件挂载会失败。 提供方与模型共同组成一条路由,必须一起提供。如果配置值、父级值或提供方持有的路由默认值能够提供生效路由,则可以只提供推理强度。静态的 `provider.agentRouteDefaults` 在存在时构成 provider/model 基线;`Config.agentOptions` 与模型参数会在路由相关强度清除之前覆盖它。没有静态默认值的提供方会使用父 Agent 最新记录请求中的兼容字段,首个请求之前由创建选项提供回退,并保留其中配置的输出 token 上限。推理强度 ID 仍由 adapter 所有。只有所选基线的路由不变时才会继承省略的强度;更换提供方或模型但没有指定强度时,会清除下层路由自有的值,使所选模型解析自己的默认值。`AgentOptions` 把结果强度传入子级循环,其请求 header 会记录生效值。可继续描述符会把它与解析后的提供方和模型一同记录,使尚未写入首个请求的子级能以相同选择冷恢复。 显式或配置的提供方、模型或强度会在提供方基线与请求优先级完成后,通过 `ctx.llm.resolveCallConfig()` 解析。具有静态路由默认值的提供方会在请求省略强度时禁止继承父级强度,从而保留所选模型的默认值。LLM 查询负责提供方注册、精确模型元数据、推理强度校验和 adapter 默认值。异步查询完成后、创建子级或后台 job 之前,工具会再次检查取消状态,并确认同一个提供方实例仍处于注册状态,因此 HMR 不会把一个提供方的默认值与另一个提供方的进程组合。既没有面向模型的选择、也没有配置路由字段的调用会保留原有提供方路径,不要求可选 LLM 服务存在。 -启用的定义会注册 `list_subagent_models`。无参数调用列出已注册提供方;提供 `provider` 时调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时解析精确模型,并返回其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。静态启用会公开不带额外过滤的实时目录。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册默认空值的 Host 自有 `subagent-model-selection.allowedModels` 设置。Plugins 设置页从适配器目录保存精确 provider/model 路由。新的顶层 Session 会在任何模型请求之前,把非空策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。在 settings 控制的 Session 中,发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 +启用的定义会注册 `list_subagent_models`。无参数调用列出已授权且已注册的提供方;提供 `provider` 时先授权,再调用该适配器的建议性模型目录;同时提供 `provider` 与 `model` 时会先授权精确路由,再解析其推理强度和默认值。因为发现工具使用全局名称,一个工具作用域最多由一个实例启用选择。随附产品组合在 Agent 作用域的主 `subagent` 实例上设置 `modelSelectionSettings: true`,并注册 Host 自有的 `subagent-model-selection` 设置,其中包含默认关闭的显式 `enabled` 开关与 `allowedModels` 列表。Plugins 设置页会原子保存两个字段。设置启用时,新的顶层 Session 会在任何模型请求之前,把路由策略快照记录为 `subagent/model-selection-policy`。子 Session 继承在线父级的策略;恢复的 Session 使用已记录事件,而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。即使缺少可选 LLM 服务,固定发现定义仍保持可用;发现调用和所选路由调用会在该服务出现前失败。发现会列出实时目录与已记录策略的交集,执行器会拒绝策略之外的显式路由。 -随附的 `subagent_fork` 实例不会启用 `enableModelSelection`,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 +随附的 `subagent_fork` 实例不会读取模型选择设置,即使进程内 fork 提供方支持 `agentOptions` 也是如此。fork 会继承父级生效的提供方与模型,使复制的对话前缀仍可供提供方侧 KV Cache 复用。更改任一路由组件都会要求新路由重新预填充继承的历史,而这项重算成本可能超过委派任务本身。该限制与发现工具的全局名称无关:分离发现工具的持有权可以让配置生效,却无法保留复用。只有在路由变化仍能保留前缀复用,或调用方可以显式限制并接受重算成本时,才重新考虑 fork 路由选择。 委派定义不会随 adapter 注册和目录变化而改变,因此实时拓扑既不会扩大每个父级请求,也不会使缓存前缀失效。只有调用发现工具时,目录结果才进入 transcript。自定义的上下文继承实例如果启用选择,其描述会警告,更改提供方或模型可能阻止提供方复用继承的对话前缀。 @@ -49,7 +49,7 @@ Status: implemented ## 结果 - 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 -- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略非空的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 +- 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 - adapter 目录和拓扑变化不会改变委派定义及其 prompt 缓存前缀。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index b9966ef055..60d872ccda 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: 3a56e48b35bcd1b1801108022e85ec83ad98b437 -2026-08-24-user-authorized-subagent-model-routes.zh.md: dca788993f1eccb96a424d2bd8186749363488b3 +2026-08-24-user-authorized-subagent-model-routes.md: af293dd2e74ea801892f2b73553cc15beb1c679e +2026-08-24-user-authorized-subagent-model-routes.zh.md: 973142f0294ad2bfc10dd5729e3a4a4992d6a84b diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index 3a56e48b35..af293dd2e7 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -10,13 +10,13 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an ## Decision -The Host-owned `subagent-model-selection` settings section stores `allowedModels`, an array of exact `{ provider, model }` routes. An empty array disables model-facing child route selection. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage one or more exact routes, and replaces the whole array in one revision-fenced field write. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. -A newly composed top-level Session snapshots a non-empty route list in `subagent/model-selection-policy` before its model-selectable definitions can reach a request. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions. +A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. The fixed `list_subagent_models` schema does not enumerate the policy. At call time, provider and model listings are the intersection of the Session route list and the adapter's live advertised directory. An exact provider/model lookup first requires authorization, then resolves the adapter-owned model metadata and all advertised reasoning efforts. The delegation executor independently rejects any explicit provider, model, or effort selection whose effective provider/model route is outside the Session list before `resolveCallConfig()` validates adapter availability and effort support. A call that supplies no selection field retains configured or inherited routing because the model made no route choice. -Static `enableModelSelection: true` remains an unrestricted deployment-owned mode for custom compositions. The shipped `modelSelectionSettings` path is user-authorized and default-off. The primary spawn tool uses that path; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. +Model selection has no unrestricted static mode. The default-off Host setting is the only authority, and an enabled Session always carries an exact allowlist. The primary spawn tool reads that setting; the shipped fork tool still exposes no route selection so inherited conversation prefixes remain eligible for provider-side KV Cache reuse. ## Alternatives considered @@ -24,7 +24,7 @@ Static `enableModelSelection: true` remains an unrestricted deployment-owned mod **Filter only the settings UI or discovery result.** Rejected because a model can guess a route or retain one from an earlier transcript. Authorization is enforced in the executor that starts the child. -**Store `enabled` and `allowedModels` as separate fields.** Rejected because two writes admit an enabled state with no completed authorization decision. A non-empty array is both the opt-in and its exact policy; an empty user-layer array can explicitly disable a deployment base list. +**Infer enablement from a non-empty `allowedModels` array.** Rejected because disabling would have to discard a useful selection or preserve a non-empty array whose meaning depends on write history. The explicit switch is authoritative, and the settings scope submits both fields in one Host-validated mutation so no intermediate state is persisted. **Store per-route reasoning-effort allowlists.** Rejected because the user decision concerns child models, while effort ids and compatibility belong to the exact adapter route. Every adapter-supported effort remains available after the route is authorized. diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index dca788993f..973142f029 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -10,13 +10,13 @@ Status: implemented ## Decision -Host 自有的 `subagent-model-selection` 设置 section 保存 `allowedModels`,即由精确 `{ provider, model }` 路由组成的数组。空数组会关闭面向模型的子级路由选择。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存一条或多条精确路由,再用一次带 revision 限制的字段写入整体替换该数组。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 -新组合的顶层 Session 会在模型可选定义进入请求之前,把非空路由列表快照记录为 `subagent/model-selection-policy`。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session。 +设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 固定的 `list_subagent_models` schema 不会枚举该策略。调用时,提供方和模型列表是 Session 路由列表与适配器实时公布目录的交集。精确 provider/model 查询先要求授权,再解析适配器自有的模型元数据和全部已公布推理强度。委派执行器还会独立拒绝任何生效 provider/model 路由不在 Session 列表内的显式提供方、模型或强度选择,然后才由 `resolveCallConfig()` 校验适配器可用性与强度支持。完全没有选择字段的调用保留配置或继承路由,因为模型没有作出路由选择。 -静态 `enableModelSelection: true` 继续作为自定义组合中由部署方所有的无限制模式。随附的 `modelSelectionSettings` 路径由用户授权且默认关闭。主 spawn 工具使用该路径;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 +模型选择不再有无限制的静态模式。默认关闭的 Host 设置是唯一授权来源,启用的 Session 始终携带精确允许列表。主 spawn 工具读取该设置;随附 fork 工具仍不公开路由选择,使继承的对话前缀继续符合提供方侧 KV Cache 复用条件。 ## Alternatives considered @@ -24,7 +24,7 @@ Host 自有的 `subagent-model-selection` 设置 section 保存 `allowedModels` **只过滤设置 UI 或发现结果。** 不采用,因为模型可以猜测路由,或从较早的 transcript 中保留路由。授权由启动子级的执行器强制执行。 -**把 `enabled` 与 `allowedModels` 存成两个字段。** 不采用,因为两次写入会产生已经启用但尚无完整授权决定的状态。非空数组同时表示 opt-in 与精确策略;用户层空数组可以显式关闭部署基础列表。 +**从非空 `allowedModels` 数组推断是否启用。** 不采用,因为关闭功能时要么必须丢弃仍有用的选择,要么要保留一个含义取决于写入历史的非空数组。显式开关是权威依据,设置 scope 会在一次由 Host 校验的 mutation 中提交两个字段,因此不会持久化中间状态。 **保存每条路由的推理强度允许列表。** 不采用,因为用户决定针对子级模型,而强度 id 与兼容性属于精确适配器路由。路由获准后,仍可使用适配器支持的每种强度。 diff --git a/apps/cli/tests/profiles/headless/cordis.yml b/apps/cli/tests/profiles/headless/cordis.yml index 759a252b4e..ea31466a70 100644 --- a/apps/cli/tests/profiles/headless/cordis.yml +++ b/apps/cli/tests/profiles/headless/cordis.yml @@ -124,7 +124,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index bb2998d51b..a351cee4ee 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -242,12 +242,16 @@ describe('the shipped Web composition', () => { }) it('applies the default-off subagent model allowlist only to new sessions', async () => { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: false, + allowedModels: [], + }) const disabled = await ctx.agents.create({ sessionId: SessionId('preset-model-selection-disabled'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, allowedModels: [{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }], }) const enabled = await ctx.agents.create({ @@ -265,7 +269,7 @@ describe('the shipped Web composition', () => { ])) expect(toolNames(ctx, disabled.agent)).not.toContain('list_subagent_models') } finally { - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) await enabled.dispose() await disabled.dispose() } diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index 781034541b..edf3b1e98f 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -106,10 +106,20 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) + expect(await settingsDocument()).toContain('enabled: true') expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') + + await toggle.click() + await dialog.getByRole('button', { name: '保存', exact: true }).click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') + await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) + .toBe(true) + expect(await settingsDocument()).toContain('allowedModels:') + expect(await settingsDocument()).toContain('provider:') + expect(await settingsDocument()).toContain('model:') expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 191f1c452c..2407817a57 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6432f5d359027a30fd436c9becd6bb6b8c0abaf4 -config-catalog.zh.md: 1ab6838e4cea77a7d98a2227aca6e8ac47d84fcd +config-catalog.md: 57b7f120ff09459e02f998806f6335f2b45d1b1b +config-catalog.zh.md: f0911b9d004d885868758351e16e3783b660eb18 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6432f5d359..57b7f120ff 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2863,12 +2863,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1ab6838e4c..f0911b9d00 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2865,12 +2865,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index a9f6e2035a..9e82c64ceb 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 792e03aa8bc5b8533094b4fd678ef8b43383e043 -subagent.zh.md: 1b5af32dfc6405c1df09d38628332e8e6c8ae737 +subagent.md: 9206672b89ac31e30bee176f536b3057eb37f3ee +subagent.zh.md: 30e4e09a145a4b3d51044d85c602ce81fd44c3b3 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 792e03aa8b..9206672b89 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -505,10 +505,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read a detached route policy for the next eligible Agent publication. - * @returns exact allowed routes; an empty list disables model-facing selection. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentAllowedModels(): AllowedModelRoute[] +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 1b5af32dfc..30e4e09a14 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -509,10 +509,10 @@ Singleton settings owner read by delegation tools when an Agent is published. ```ts cordis-catalog /** - * Read a detached route policy for the next eligible Agent publication. - * @returns exact allowed routes; an empty list disables model-facing selection. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ -currentAllowedModels(): AllowedModelRoute[] +current(): SubagentModelSelectionSettings ``` Source: [`packages/subagent/tool-subagent/src/model-selection-settings.ts`](../../packages/subagent/tool-subagent/src/model-selection-settings.ts) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 46e2eebdd3..569d755993 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: be8f503ed983e68e39da1a9401ffbe70a029968c -tool-catalog.zh.md: 16fd7de1235250d91dfa7df2304d1a66ba192b0d +tool-catalog.md: 16142c2f7d98cf1037b034d2836c742b62f26594 +tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index be8f503ed9..16142c2f7d 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -33,7 +33,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`, `ctx.systemPrompt`, `a live continuable in-process child Agent` | `tool/call`, `tool/result`, `a user-role message in the direct parent session` | - | Registered per continuable in-process child rather than globally, so this schema is visible only inside such a child and survives its global `toolFilter`. The same contribution installs the child-scoped `tool:report` prompt section, which this catalog does not render. The parent-facing `send_message` tool is installed independently. | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. | @@ -1561,7 +1561,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag ### `subagent` -Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. +Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This call waits for the result by default. Set `run_in_background: true` to return a job id; collect with `job_output` and stop with `job_kill`. ```json { @@ -1575,18 +1575,6 @@ Delegate a self-contained task to a subagent (a separate agent that works in its "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1601,7 +1589,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. +The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 16fd7de123..533caacc7a 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -37,7 +37,7 @@ | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`、`session_event_search`、`session_event_trace`、`session_search`、`session_trace` | `ctx.tools`、`ctx.systemPrompt`、`ctx.sessionQuery`、`a calling Agent for workspace authority` | `tool/call`、`tool/result` | - | 这 5 个只读工具会隐藏提供方游标,并根据不可变的调用 agent 会话为每个结果授权。该包需要选择启用;需要强制截止时间或限制行内输出的组合还会挂载通用超时或 spill 策略。 | -| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 | +| `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 | | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 | | `@deepseek-ai/dsh-tool-subagent-report` | `report` | `ctx.subagents`、`ctx.systemPrompt`、`a live continuable in-process child Agent` | `tool/call`、`tool/result`、`a user-role message in the direct parent session` | - | 按可继续的进程内子级注册,而非全局注册,因此该 schema 仅在这种子级内部可见,并且不受其全局 `toolFilter` 影响。同一份贡献还会安装子级作用域的 `tool:report` 系统提示词 section,本目录不渲染该 section。面向父级的 `send_message` 工具单独安装。 | | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 | @@ -1567,7 +1567,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, ### `subagent` -将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。子级 LLM 选择是可选的。省略 `provider`、`model` 与 `reasoning_effort` 会使用配置的子级默认值,并从父 Agent 继承兼容的缺失值。先用 `list_subagent_models` 检查公布的路由和强度,再一起提供 `provider` 与 `model`。改变生效路由但不指定强度时,会使用所选模型的默认强度。 +将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。请提供完整、独立的提示词,因为它看不到当前对话。此调用默认等待结果。设置 `run_in_background: true` 可返回 job id;使用 `job_output` 收集结果,使用 `job_kill` 停止任务。 ```json { @@ -1581,18 +1581,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "Whether to run as a background job and return its id. Defaults to false; collect with job_output or stop with job_kill." @@ -1607,7 +1595,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, 来源:[`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述 schema 以静态启用模型选择作为参考。模型选择默认为关闭。Web preset 会在每个新顶层 Session 创建时读取插件页中默认关闭的偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。显式组合也可以改用静态 `enableModelSelection`。每个实例通过 `enableModelSelection`、`modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制模型选择、发现工具持有权和后台行为。 +注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index ee5e5ed156..eaa8457e88 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -357,7 +357,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable # Fork omits model selection so provider/model stay equal to the parent and diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index d8cfb62f6c..b2f24d4b8e 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -20,8 +20,10 @@ export interface AllowedSubagentModel { /** Settings fields stored for subagent model selection. */ export interface SubagentModelSelectionSettings { + /** Whether model-facing child route selection applies to new Sessions. */ + enabled: boolean /** Exact child routes offered to newly composed top-level Sessions. */ - allowedModels?: AllowedSubagentModel[] + allowedModels: AllowedSubagentModel[] } /** One catalog row joined with a stored route that may no longer be advertised. */ @@ -64,7 +66,7 @@ export interface SubagentModelSelectionCardFace { toggleModel: (key: string) => void /** Retry the adapter directory. */ retryCatalog: () => void - /** Persist the whole exact route list as one revision-fenced field write. */ + /** Persist the switch and exact routes as one revision-fenced mutation. */ save: () => void /** Drop the staged enabled state and route choices. */ discard: () => void @@ -151,9 +153,10 @@ export class SubagentModelSelectionCardController { ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { - if (this.currentRoutes().length > 0 && this.catalogStatus === 'idle') void this.loadCatalog() + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() }) + if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() } /** Stop observing settings and suppress late directory/write settlements. */ @@ -180,7 +183,11 @@ export class SubagentModelSelectionCardController { } private currentRoutes(): AllowedSubagentModel[] { - return this.scope.getSnapshot().value?.allowedModels?.map(route => ({ ...route })) ?? [] + return this.scope.getSnapshot().value?.allowedModels.map(route => ({ ...route })) ?? [] + } + + private currentEnabled(): boolean { + return this.scope.getSnapshot().value?.enabled ?? false } private selected(): Set { @@ -188,11 +195,11 @@ export class SubagentModelSelectionCardController { } private enabled(): boolean { - return this.draftEnabled ?? this.currentRoutes().length > 0 + return this.draftEnabled ?? this.currentEnabled() } private beginDraft(): Set { - this.draftEnabled ??= this.currentRoutes().length > 0 + this.draftEnabled ??= this.currentEnabled() this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey)) return this.draftSelected } @@ -233,7 +240,6 @@ export class SubagentModelSelectionCardController { } private desiredRoutes(): AllowedSubagentModel[] { - if (!this.enabled()) return [] return this.candidates() .filter(candidate => candidate.selected) .map(({ provider, model }) => ({ provider, model })) @@ -241,17 +247,22 @@ export class SubagentModelSelectionCardController { private async save(): Promise { const snapshot = this.scope.getSnapshot() + const desiredEnabled = this.enabled() const desired = this.desiredRoutes() if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving - || sameRoutes(this.currentRoutes(), desired) || (this.enabled() && desired.length === 0)) return + || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) + || (desiredEnabled && desired.length === 0)) return const generation = this.saveGeneration this.saving = true this.saved = false this.failed = false this.publish() - await this.scope.set('allowedModels', desired) + await this.scope.mutate([ + { op: 'set', path: ['enabled'], value: desiredEnabled }, + { op: 'set', path: ['allowedModels'], value: desired }, + ]) if (generation !== this.saveGeneration) return - const landed = sameRoutes(this.currentRoutes(), desired) + const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false this.saved = landed this.failed = !landed @@ -291,7 +302,7 @@ export class SubagentModelSelectionCardController { return { available: snapshot.status === 'ready', writable: snapshot.writable, - dirty: !sameRoutes(current, desired), + dirty: this.currentEnabled() !== enabled || !sameRoutes(current, desired), invalid: enabled && desired.length === 0, saving: this.saving, failed: this.failed, diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 414ce8dab9..853bb502c9 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { CardForm, numberField, textField } from '../src/client/card-form.ts' import { AgentLoopCardController, type AgentLoopSettings } from '../src/client/agent-loop-card-controller.ts' @@ -26,6 +27,18 @@ function acceptWrites(host: StubSettingsScope): void { host.set.mockImplementation((field: string, value: unknown) => { host.publish({ value: { ...section(), [field]: value } as T, user: { ...layer(), [field]: value } }) }) + host.mutate.mockImplementation((ops: readonly SettingsPathOpView[]) => { + const value = { ...section() } + const user = { ...layer() } + for (const op of ops) { + const field = op.path[0]! + if (op.op === 'set') { + value[field] = op.value + user[field] = op.value + } + } + host.publish({ value: value as T, user }) + }) host.unset.mockImplementation((field: string) => { const user = Object.fromEntries(Object.entries(layer()).filter(([key]) => key !== field)) const base = host.scope.getSnapshot().base as Record | undefined @@ -436,14 +449,14 @@ describe('SubagentModelSelectionCardController', () => { ]) }) - it('loads adapter models and saves one exact route as a whole field', async () => { + it('loads adapter models and saves the switch and routes atomically', async () => { const host = stubSettingsScope() acceptWrites(host) const models = modelsApi({ groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) @@ -454,7 +467,10 @@ describe('SubagentModelSelectionCardController', () => { face.toggleModel('alpha\0fast') face.save() await vi.waitFor(() => { - expect(host.set).toHaveBeenCalledWith('allowedModels', [{ provider: 'alpha', model: 'fast' }]) + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ]) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ @@ -472,7 +488,7 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() face.toggleEnabled() @@ -502,7 +518,7 @@ describe('SubagentModelSelectionCardController', () => { const controller = new SubagentModelSelectionCardController(host.scope, models.api) host.publish({ status: 'ready', writable: true, - value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, }) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -521,11 +537,38 @@ describe('SubagentModelSelectionCardController', () => { expect(state()).toMatchObject({ dirty: false, enabled: true }) }) + it('retains selected routes when disabling and loads an already-ready enabled card', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, + }) + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + await vi.waitFor(() => { expect(models.models).toHaveBeenCalledOnce() }) + + face.toggleEnabled() + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: false }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ]) + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: false, dirty: false, saved: true, + }) + }) + it('reports a directory error and retries it', async () => { const host = stubSettingsScope() const models = modelsApi({ error: 'offline' }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -541,16 +584,21 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const write = deferred() - const set = vi.fn(async (field: string, value: unknown) => { + const mutate = vi.fn(async (ops: readonly SettingsPathOpView[]) => { await write.promise - host.publish({ value: { [field]: value } }) + const enabled = ops.find(op => op.path[0] === 'enabled') + const allowedModels = ops.find(op => op.path[0] === 'allowedModels') + host.publish({ value: { + enabled: enabled?.op === 'set' ? enabled.value as boolean : false, + allowedModels: allowedModels?.op === 'set' ? allowedModels.value as never[] : [], + } }) }) - const controller = new SubagentModelSelectionCardController({ ...host.scope, set }, catalog.api) + const controller = new SubagentModelSelectionCardController({ ...host.scope, mutate }, catalog.api) const face = controller.inject() face.save() face.toggleModel('alpha\0fast') - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) face.save() face.toggleEnabled() await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().catalogStatus).toBe('ready') }) @@ -565,12 +613,12 @@ describe('SubagentModelSelectionCardController', () => { controller.dispose() write.resolve(undefined) await write.promise - expect(set).toHaveBeenCalledOnce() + expect(mutate).toHaveBeenCalledOnce() }) it('suppresses duplicate directory loads and late resolve or reject settlements', async () => { const host = stubSettingsScope() - host.publish({ status: 'ready', writable: true, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) const pending = deferred() const models = vi.fn(() => pending.promise) @@ -601,20 +649,20 @@ describe('SubagentModelSelectionCardController', () => { it('ignores writes while read-only and scope notifications after disposal', () => { const host = stubSettingsScope() const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) - host.publish({ status: 'ready', writable: false, value: { allowedModels: [] }, user: {} }) + host.publish({ status: 'ready', writable: false, value: { enabled: false, allowedModels: [] }, user: {} }) const face = controller.inject() face.toggleEnabled() face.toggleModel('alpha\0fast') face.save() - expect(host.set).not.toHaveBeenCalled() + expect(host.mutate).not.toHaveBeenCalled() controller.dispose() face.toggleEnabled() face.retryCatalog() face.save() - host.publish({ value: { allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) - expect(host.set).not.toHaveBeenCalled() + host.publish({ value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] } }) + expect(host.mutate).not.toHaveBeenCalled() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) }) }) diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 6184db0dff..06c0fe9604 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: a9a45d90eaa46502829ee6d2c1793b7dadb1f0a5 -README.zh.md: bd615cbb5a3236370b7bbc9fc735deeab6efd8ce +README.md: beda3aec1750da77af86cebdf658bde8e3834a46 +README.zh.md: 8d4fb0983c8a0ba627411fd3b653114625530639 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index a9a45d90ea..beda3aec17 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: one field path fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. +A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. ### Filling the settings slots @@ -55,7 +55,7 @@ The plugin injects `connection` and `remote` and owns the one `settings.describe ### Scope derivation -`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope with the namespace revision as `expectedRevision`; a committed write folds its answer in, a rejected or failed latest write triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. +`ctx.settingsScope.bind(spec)` returns a per-namespace scope derived from the mirror on the caller's context: the scope's disposer belongs to the calling fiber, binding adds no wire read, and a row's activation never blocks on the settings transport. Writes stay per-scope: `set` and `unset` are single-operation forms of `mutate`, which copies and queues several ordered field operations behind one namespace revision as `expectedRevision`. A committed mutation folds its answer in, a rejected or failed latest mutation triggers one recovery read, and a superseded one leaves recovery to its successor. The cold-boot read count is pinned by `../../../apps/web/tests/startup-rpc-budget.e2e.ts`; a new direct `settings.describe` caller in client code is a regression against it. ### Schema service @@ -95,7 +95,6 @@ None; this package neither assembles nor sends a provider request. These limits define where the settings transport cannot reach; they are current package constraints. - **Non-loopback pages get no durable settings** — this Client keeps Host persistence disabled there, so a scope starts `unavailable` and never crosses the wire; every row it backs is inert even though Connection authentication covers the API. -- **One field per write** — `set` sends a single `set` op, so a row that must move two fields together has no transaction and publishes two revisions. ### Dev Note diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index bd615cbb5a..8d4fb0983c 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:单一字段路径以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 +功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 ### 填充设置 slot @@ -55,7 +55,7 @@ kind: "package-reference" ### Scope 派生 -`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope,以命名空间 revision 作为 `expectedRevision` 围栏;提交成功的写入把应答折回镜像,被拒绝或失败的最新写入触发一次恢复读取,被取代的写入把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 +`ctx.settingsScope.bind(spec)` 在调用方的 context 上返回一个由镜像派生的按命名空间 scope:scope 的 disposer 归调用方 fiber 所有,绑定不新增任何线路读取,某一行的激活绝不会阻塞在设置传输层上。写入仍归各 scope:`set` 与 `unset` 是 `mutate` 的单操作形式,后者会复制操作列表,并把多个有序字段操作排在同一个作为 `expectedRevision` 的命名空间 revision 之后。提交成功的 mutation 把应答折回镜像,被拒绝或失败的最新 mutation 触发一次恢复读取,被取代的 mutation 把恢复留给后继者。冷启动读取次数由 `../../../apps/web/tests/startup-rpc-budget.e2e.ts` 钉住;客户端代码中新增直连 `settings.describe` 调用即是对它的回归。 ### Schema 服务 @@ -95,7 +95,6 @@ kind: "package-reference" 这些限制说明设置传输层够不到的地方;它们是当前包约束。 - **非 loopback 页面没有持久化设置**:本 Client 在那里禁用 Host 持久化,因此 scope 以 `unavailable` 起步且从不跨线路;尽管 Connection 认证覆盖 API,它支撑的每一行仍在那里无效。 -- **每次写入仅一个字段**:`set` 只发送单个 `set` op,因此需要同时改动两个字段的行没有事务可用,会发布两个 revision。 ### 开发备注 diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 38f95177a7..05b459c7a5 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -2,6 +2,8 @@ * Settings-namespace scope contracts owned beside the settings transport. */ +import type { SettingsPathOpView } from '@deepseek-ai/dsh-api-remotes/client' + /** Client-side sync state of one settings namespace. */ export interface SettingsScopeSnapshot { /** @@ -46,7 +48,8 @@ export interface SettingsScopeSpec { /** * Reactive owner handle over one namespace's durable section — the browser * mirror of the Host-side `SettingsScope` owner seam. Domain services read - * and observe the snapshot and route explicit user choices through `set`. + * and observe the snapshot and route explicit user choices through its + * mutation methods. */ export interface SettingsScope { /** @returns the current sync snapshot (stable reference until the next change). */ @@ -57,6 +60,13 @@ export interface SettingsScope { * @returns the disposer removing this listener. */ subscribe(listener: () => void): () => void + /** + * Queue one atomic namespace mutation. All operations share one revision + * fence, Host validation, persistence decision, and recovery read. + * @param ops - ordered field operations copied when queued. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[]): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index d4ddd632e5..276adebb46 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -104,7 +104,7 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the write and any latest-write recovery read. */ set(field: string, value: unknown): Promise { - return this.write({ op: 'set', path: [field], value: value as JsonValue }) + return this.mutate([{ op: 'set', path: [field], value: value as JsonValue }]) } /** @@ -114,16 +114,22 @@ export class SettingsScopeController implements SettingsScope { * @returns settlement after the clear and any latest-write recovery read. */ unset(field: string): Promise { - return this.write({ op: 'unset', path: [field] }) + return this.mutate([{ op: 'unset', path: [field] }]) } - private write(op: SettingsPathOpView): Promise { + /** + * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}. + * @param ops - ordered field operations copied when queued. + * @returns settlement after the mutation and any latest-write recovery read. + */ + mutate(ops: readonly SettingsPathOpView[]): Promise { + const ownedOps = structuredClone(ops) as SettingsPathOpView[] const generation = ++this.writeGeneration return this.enqueue(async () => { const revision = this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { - response = await this.api.settings.mutate(this.spec.namespace, [op], revision) + response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) } catch (_settingsWriteFailure) { await this.recover(generation) return diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index b6788f0dba..883beee615 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -1,7 +1,9 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' -import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { + JsonValue, SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' import { TestRemote } from '@deepseek-ai/dsh-client-test-runtime' import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client' import { SettingsSchemaService } from '../src/client/schema.ts' @@ -174,6 +176,31 @@ describe('SettingsScopeController', () => { ) }) + it('sends one copied multi-field mutation behind one revision fence', async () => { + const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 7)) + const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 8))) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + const ops: SettingsPathOpView[] = [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ] + + const write = scope.mutate(ops) + ops[0] = { op: 'unset', path: ['enabled'] } + ;(ops[1] as { value: Array<{ model: string }> }).value[0]!.model = 'changed' + await write + + expect(mutate).toHaveBeenCalledWith( + 'ui-test', + [ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], + 7, + ) + }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index abb2272981..f6d8110ae0 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2086,10 +2086,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Singleton settings owner read by delegation tools when an Agent is published.', methods: [ { - signature: 'currentAllowedModels(): AllowedModelRoute[]', - description: 'Read a detached route policy for the next eligible Agent publication.', + signature: 'current(): SubagentModelSelectionSettings', + description: 'Read a detached selection preference for the next eligible Agent publication.', parameters: [], - returns: 'exact allowed routes; an empty list disables model-facing selection.', + returns: 'the enabled state and exact allowed routes.', }, ], }, @@ -3362,10 +3362,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentStatus', declaration: 'export type AgentStatus = \'idle\' | \'running\';', }, - { - name: 'AllowedModelRoute', - declaration: 'export interface AllowedModelRoute {\n readonly provider: string;\n readonly model: string;\n}', - }, { name: 'ApiKeyRecord', declaration: 'export interface ApiKeyRecord {\n readonly kind: \'api-key\';\n readonly key?: string;\n readonly env?: Readonly>;\n}', diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index 66f31e12f5..e34f464d28 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: 72874e94b792ef3e75e4fa0d6ed7475808f209a0 -README.zh.md: 1cab6922b93c0fd8776158f1659927d0c4aa4126 +README.md: 5225aa28719e92b2158951aa145d9335a67ff1a2 +README.zh.md: 702cf46d36e15c7524c3dffc5dd46035547ff768 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 72874e94b7..5225aa2871 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -44,8 +44,7 @@ Load the subagent service, an in-process or remote backend, and this tool; then |---|---|---| | `provider` | required | Provider name on `ctx.subagents` (e.g. `spawn`, `fork`, `acp`) | | `toolName` | `subagent` | Model-facing tool name; distinct for every loaded instance | -| `enableModelSelection` | `false` | Statically expose child LLM selection fields and register `list_subagent_models`; requires provider `agentOptions` support | -| `modelSelectionSettings` | `false` | Sample the Host preference for each new top-level Session; mutually exclusive with `enableModelSelection` and valid only in Agent scope | +| `modelSelectionSettings` | `false` | Sample the Host's exact-route authorization preference for each new top-level Session; valid only in Agent scope and requires provider `agentOptions` support | | `enableRunInBackground` | `true` | Expose `run_in_background`; disabling also rejects forced background calls | | `backgroundMode` | `one-shot` | Background policy: `one-shot` defaults calls to foreground; `continuable` defaults them to background and requires the provider's `prepareContinuable` capability | | `agentOptions` | — | Configured child `provider`, `model`, adapter-owned `reasoningEffort`, and positive `maxTokens` defaults; requires provider `agentOptions` support and overlays any provider-owned route defaults | @@ -65,7 +64,7 @@ Under `continuable` policy, an omitted or `true` `run_in_background` starts a du ### Selecting a child LLM -Set `enableModelSelection: true` to expose optional `provider`, `model`, and `reasoning_effort` fields and register the shared `list_subagent_models` tool. Alternatively, set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection.enabled` preference when each top-level Session is composed. That decision is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. These modes are mutually exclusive and require a backend that advertises `agentOptions`; ACP, Codex, and Claude Code reject them, while DSH SDK supports route selection. +Set `modelSelectionSettings: true` to sample the Host's `subagent-model-selection` preference when each top-level Session is composed. When enabled, its non-empty exact provider/model route list is recorded in the Session, inherited by child Sessions, and unchanged by later settings edits. The tool then exposes optional `provider`, `model`, and `reasoning_effort` fields and registers the shared `list_subagent_models` tool. This mode requires a backend that advertises `agentOptions`; both in-process backends and DSH SDK support it, while ACP, Codex, and Claude Code reject it rather than ignore it. A call supplies `provider` and `model` together, or supplies only an effort when configured, parent, or provider-owned defaults provide the route. Static `provider.agentRouteDefaults`, when present, form the provider/model baseline; tool configuration and model fields overlay it before route-aware effort merging and exact-route preflight. Providers without these defaults use compatible values from the parent's latest logged request, then the parent's creation options before its first request, while retaining the configured `maxTokens`. Changing the route without an explicit effort clears the inherited route-owned effort, so the selected model resolves its default. The live LLM adapter validates the effective route before child creation. Catalog membership remains advisory, so a model can use an unlisted id when its adapter accepts it. @@ -132,7 +131,7 @@ Read these pages when the package-level contract is not enough; they move from t #### 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. Model selection adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance. The tool and prompt descriptions follow whether the child inherits the conversation. Enabled background mode adds `run_in_background`: continuable mode documents its `true` default and the settlement notice, while one-shot mode documents its `false` default and job collection. A tool restriction removes both the schema and the guidance section. +The generated default [`subagent` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-subagent) under this instance's configured name while its provider exists. An enabled Session policy adds `provider`, `model`, and `reasoning_effort` plus inheritance and selection guidance; the provider must support `agentOptions`. 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 job id collected with `job_output` or stopped with `job_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 @@ -146,7 +145,7 @@ Prefix-stable while provider instances and their configuration are unchanged. Ad #### What the model sees -An instance with static `enableModelSelection: true`, or a settings-controlled instance whose Session policy is non-empty, exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Static enablement returns the live adapter directory. A settings-controlled instance returns only registered providers and advertised models in its exact route policy; an exact lookup must also be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy. +A settings-controlled instance whose Session carries a policy exposes the child LLM selection fields and `list_subagent_models`. Calls reject while the optional `ctx.llm` service is unavailable. Discovery returns only registered providers and advertised models in the exact route policy; an unauthorized provider is rejected before its adapter catalog is called, and an exact lookup must be allowed before it resolves the model's reasoning efforts and default. Execution independently enforces the same policy. #### Token effect @@ -213,8 +212,8 @@ These limits define what this tool does not return or enforce; they are current - **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. -- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable selection only when route changes preserve reuse or expose a bounded recomputation cost. -- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM selection requires static enablement or an enabled per-Session preference and a provider that advertises `agentOptions`; ACP, Codex, and Claude Code reject it rather than ignore it. +- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable the fields only when route changes preserve reuse or expose a bounded recomputation cost. +- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM provider/model/reasoning-effort selection requires an enabled per-Session preference and a subagent provider that advertises `agentOptions`; out-of-process providers currently reject enabling it rather than ignore it. ### Dev Note diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 1cab6922b9..702cf46d36 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -44,8 +44,7 @@ kind: "package-reference" |---|---|---| | `provider` | 必填 | `ctx.subagents` 上的提供方名称(如 `spawn`、`fork`、`acp`) | | `toolName` | `subagent` | 面向模型的工具名称;每个已加载实例必须不同 | -| `enableModelSelection` | `false` | 静态公开子级 LLM 选择字段并注册 `list_subagent_models`;要求提供方支持 `agentOptions` | -| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主偏好;与 `enableModelSelection` 互斥,且只在 Agent 作用域内有效 | +| `modelSelectionSettings` | `false` | 为每个新顶层 Session 读取宿主的精确路由授权偏好;只在 Agent 作用域内有效,并要求提供方支持 `agentOptions` | | `enableRunInBackground` | `true` | 公开 `run_in_background`;禁用时也会拒绝强制后台调用 | | `backgroundMode` | `one-shot` | 后台策略:`one-shot` 默认前台调用;`continuable` 默认后台调用,并要求提供方具备 `prepareContinuable` 能力 | | `agentOptions` | — | 配置的子级 `provider`、`model`、适配器所有的 `reasoningEffort` 与正整数 `maxTokens` 默认值;要求提供方支持 `agentOptions`,并会覆盖提供方持有的路由默认值 | @@ -65,7 +64,7 @@ kind: "package-reference" ### 选择子级 LLM -设置 `enableModelSelection: true` 可公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。也可以设置 `modelSelectionSettings: true`,在组合每个顶层 Session 时读取宿主的 `subagent-model-selection.enabled` 偏好。该决定会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。这两种模式互斥,且要求后端声明 `agentOptions`;ACP、Codex 与 Claude Code 会拒绝它们,DSH SDK 则支持路由选择。 +设置 `modelSelectionSettings: true`,即可在组合每个顶层 Session 时读取宿主的 `subagent-model-selection` 偏好。启用后,非空的精确 provider/model 路由列表会记录进 Session、由子 Session 继承,后续设置编辑不会改变它。工具随后公开可选的 `provider`、`model` 与 `reasoning_effort` 字段,并注册共享的 `list_subagent_models` 工具。此模式要求后端声明 `agentOptions`;两个进程内后端和 DSH SDK 支持该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 一次调用需同时提供 `provider` 与 `model`;当配置值、父 agent 值或提供方持有的默认值能提供路由时,也可只提供推理等级。静态的 `provider.agentRouteDefaults` 在存在时构成提供方/模型基线;工具配置与模型字段会在路由相关强度合并和确切路由预检前覆盖它。没有这些默认值的提供方会使用父 agent 最新已记录请求中的兼容值,再使用父级首次请求前的创建选项,并保留配置的 `maxTokens`。更改路由但未显式提供推理等级时,会清除继承的路由自有等级,使所选模型解析自己的默认值。实时 LLM 适配器在创建子 agent 前校验有效路由。目录成员资格只提供建议,因此适配器接受时,模型可以使用未列出的 id。 @@ -132,7 +131,7 @@ kind: "package-reference" #### 模型看到什么 -当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。模型选择会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引。工具与提示词描述会随子 agent 是否继承对话而调整。启用后台模式会添加 `run_in_background`:可继续模式记录其默认值为 `true` 及结算通知,一次性模式记录其默认值为 `false` 及任务收集。工具限制会同时移除 schema 与指导 section。 +当提供方存在时,以当前实例配置的名称公开已生成的默认 [`subagent` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-subagent)。启用的 Session 策略会添加 `provider`、`model` 与 `reasoning_effort`,以及继承和选择指引;提供方必须支持 `agentOptions`。提供方是否继承上下文会改变工具描述和提示词描述。启用后台模式会添加 `run_in_background`:可继续模式会记录其默认值为 `true`、运行时结算通知与显式前台覆盖;一次性模式会记录其默认值为 `false`,以及用 `job_output` 收集或用 `job_kill` 停止的 job id。当工具在本次组装的作用域中可见时,一个 `tool:` 系统提示词 section 会指示模型同时启动相互独立的可继续委派、在它们运行时继续工作,并且仅当下一步动作依赖结果时选择前台;工具限制会同时移除其 schema 和这段指引。 #### Token 影响 @@ -146,7 +145,7 @@ kind: "package-reference" #### 模型看到什么 -静态配置 `enableModelSelection: true` 的实例,或 Session 策略非空的 settings 控制实例,会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。静态启用返回实时适配器目录。settings 控制实例只返回其精确路由策略中的已注册提供方与已公布模型;精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。 +Session 携带策略的 settings 控制实例会公开子级 LLM 选择字段与 `list_subagent_models`。可选 `ctx.llm` 服务不可用时,调用会失败。发现只返回精确路由策略中的已注册提供方与已公布模型;未授权提供方会在调用其适配器目录前被拒绝,精确查询也必须先获准,才会解析模型的推理强度与默认值。执行阶段会独立强制同一策略。 #### Token 影响 @@ -213,8 +212,8 @@ Use subagent in the background by default. Start independent delegations togethe - **后台运行不通过本工具公开结果**——一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`)——可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 -- **随附 fork 工具不能选择子级 LLM 路由**——它们继承父级提供方与模型,使复制的对话前缀仍有资格复用 KV Cache。仅当路由变更能保留复用或公开有界重算成本时,才重新启用选择。 -- **非路由子 agent 策略按实例固定**——另一个 persona、工具过滤器或深度上限需要另一个名称不同的工具。LLM 选择要求静态启用或已启用的逐 Session 偏好,且提供方必须声明 `agentOptions`;ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 +- **随附 fork 工具无法选择子级 LLM 路由**:它们会继承父级的提供方与模型,使复制的对话前缀仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才重新启用这些字段。 +- **每个实例的非路由子 agent 策略固定**:其他 persona、工具过滤器或深度上限都需要另一个名称不同的工具。LLM 提供方/模型/推理强度选择要求每 Session 偏好已启用,并要求 subagent 提供方声明 `agentOptions`;进程外提供方目前会拒绝启用它,而不是忽略它。 ### 开发备注 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 5304d0bcc7..30a5f148a1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -54,12 +54,9 @@ export interface Config { * a distinct name. */ toolName?: string - /** Let the model discover and select the child LLM route (default false). */ - enableModelSelection?: boolean /** * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. Mutually - * exclusive with `enableModelSelection`. + * top-level session and inherit that decision in its child sessions. */ modelSelectionSettings?: boolean /** @@ -109,7 +106,6 @@ export interface Config { export const Config: z = z.object({ provider: z.string().required(), toolName: z.string().default('subagent'), - enableModelSelection: z.boolean().default(false), modelSelectionSettings: z.boolean().default(false), enableRunInBackground: z.boolean().default(true), backgroundMode: z.union(['one-shot', 'continuable'] as const).default('one-shot'), @@ -317,14 +313,11 @@ 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') } - if (config.enableModelSelection === true && config.modelSelectionSettings === true) { - throw new Error('tool-subagent: `enableModelSelection` and `modelSelectionSettings` are mutually exclusive') - } const backgroundEnabled = config.enableRunInBackground !== false const continuable = (config.backgroundMode ?? 'one-shot') === 'continuable' const toolName = config.toolName ?? 'subagent' - const modelSelectionCapable = config.enableModelSelection === true || config.modelSelectionSettings === true + const modelSelectionCapable = config.modelSelectionSettings === true const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (typeof config.maxDepth === 'number' && !subagentProvider.capabilities.depthLimit) { @@ -607,7 +600,7 @@ export function apply(ctx: Context, config: Config): void { } if (config.modelSelectionSettings !== true) { - install(ctx, config.enableModelSelection === true ? { kind: 'unrestricted' } : undefined) + install(ctx, undefined) return } @@ -633,12 +626,12 @@ export function apply(ctx: Context, config: Config): void { const parent = ctx.get('agents')?.get(parentId) allowedModels = parent === undefined ? undefined : subagentModelSelectionPolicy(parent.session) } else if (agent.session.firstLiveSeq === 0) { - const current = settings.currentAllowedModels() - allowedModels = current.length === 0 ? undefined : current + const current = settings.current() + allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) recordSubagentModelSelection(agent.session, allowedModels) - return allowedModels === undefined ? undefined : { kind: 'allowlist', routes: allowedModels } + return allowedModels === undefined ? undefined : { routes: allowedModels } } const agent = ctx.agent diff --git a/packages/subagent/tool-subagent/src/list-models.ts b/packages/subagent/tool-subagent/src/list-models.ts index 61158ca103..a1265934d3 100644 --- a/packages/subagent/tool-subagent/src/list-models.ts +++ b/packages/subagent/tool-subagent/src/list-models.ts @@ -12,11 +12,18 @@ interface ListSubagentModelsRequest { } /** Resolve one registered provider with a model-correctable diagnostic. */ -function registeredProvider(llm: LlmRuntime, providerId: string): LlmProviderInfo { +function registeredProvider( + llm: LlmRuntime, + policy: ModelSelectionPolicy, + providerId: string, +): LlmProviderInfo { const providers = llm.listProviders() const provider = providers.find(candidate => candidate.id === providerId) if (provider !== undefined) return provider - const available = providers.map(candidate => candidate.id).join(', ') || '(none)' + const available = providers + .filter(candidate => policy.routes.some(route => route.provider === candidate.id)) + .map(candidate => candidate.id) + .join(', ') || '(none)' throw new Error(`LLM provider "${providerId}" is not registered; available providers: ${available}`) } @@ -40,24 +47,27 @@ async function listSubagentModels( throw new Error('`model` requires `provider`') } if (request.provider === undefined) { - const providers = llm.listProviders().filter(provider => policy.kind === 'unrestricted' - || policy.routes.some(route => route.provider === provider.id)) + const providers = llm.listProviders() + .filter(provider => policy.routes.some(route => route.provider === provider.id)) return providers.length === 0 ? '(no LLM providers)' : providers.map(provider => `${provider.id} — ${provider.name}`).join('\n') } if (request.provider.length === 0) throw new Error('`provider` must be non-empty') - const provider = registeredProvider(llm, request.provider) + const allowedRoutes = policy.routes.filter(route => route.provider === request.provider) + if (allowedRoutes.length === 0) { + throw new Error(`LLM provider "${request.provider}" is not allowed for this Session`) + } + const provider = registeredProvider(llm, policy, request.provider) if (request.model === undefined) { - const models = (await llm.listModels(provider.id)).filter(model => policy.kind === 'unrestricted' - || policy.routes.some(route => route.provider === provider.id && route.model === model.id)) + const models = (await llm.listModels(provider.id)) + .filter(model => allowedRoutes.some(route => route.model === model.id)) return models.length === 0 ? `(no advertised models for ${provider.id})` : models.map(model => modelLine(provider.id, model)).join('\n') } if (request.model.length === 0) throw new Error('`model` must be non-empty') - if (policy.kind === 'allowlist' - && !policy.routes.some(route => route.provider === provider.id && route.model === request.model)) { + if (!allowedRoutes.some(route => route.model === request.model)) { throw new Error(`child LLM route "${provider.id}/${request.model}" is not allowed for this Session`) } const model = await llm.resolveModelInfo(provider.id, request.model, signal) diff --git a/packages/subagent/tool-subagent/src/model-selection-settings.ts b/packages/subagent/tool-subagent/src/model-selection-settings.ts index f12aae331c..74f59a32b4 100644 --- a/packages/subagent/tool-subagent/src/model-selection-settings.ts +++ b/packages/subagent/tool-subagent/src/model-selection-settings.ts @@ -21,17 +21,22 @@ export const SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE = settingsNamespace('su /** Stored user preference; the shipped composition defaults it off. */ export interface SubagentModelSelectionSettings { + /** Whether newly composed top-level Sessions receive model selection. */ + enabled: boolean /** Exact child LLM routes offered to newly composed top-level Sessions. */ allowedModels: AllowedModelRoute[] } /** Schema served to settings clients for the opt-in preference. */ export const SUBAGENT_MODEL_SELECTION_SETTINGS_SCHEMA: z = z.object({ + enabled: z.boolean().default(false), allowedModels: z.array(AllowedModelRouteSchema).default([]), }) /** Optional deployment base for the preference. */ export interface Config { + /** Initial enabled state inherited when the user document does not override it. */ + enabled?: boolean /** Initial route list inherited when the user document does not override it. */ allowedModels?: AllowedModelRoute[] } @@ -39,6 +44,7 @@ export interface Config { /** Singleton settings owner read by delegation tools when an Agent is published. */ export class SubagentModelSelectionConfig extends Service { static Config: z = z.object({ + enabled: z.boolean().default(false), allowedModels: z.array(AllowedModelRouteSchema).default([]), }) @@ -48,8 +54,11 @@ export class SubagentModelSelectionConfig extends Service { super(ctx, 'subagentModelSelection') // Cordis supplies the schema default; the fallback also covers direct construction. /* v8 ignore next */ - const entry: SubagentModelSelectionSettings = { allowedModels: config.allowedModels ?? [] } - assertAllowedModelRoutes(entry.allowedModels) + const entry: SubagentModelSelectionSettings = { + enabled: config.enabled ?? false, + allowedModels: config.allowedModels ?? [], + } + this.validate(entry) this.source = () => entry installSettingsSection( ctx, @@ -58,7 +67,7 @@ export class SubagentModelSelectionConfig extends Service { entry, { setSource: (source) => { this.source = source }, - validate: (value) => { assertAllowedModelRoutes(value.allowedModels) }, + validate: (value) => { this.validate(value) }, // Consumers sample at Agent publication, so a settings update never // rebuilds the tool definitions of an Agent that is already running. onChange: () => {}, @@ -67,11 +76,22 @@ export class SubagentModelSelectionConfig extends Service { } /** - * Read a detached route policy for the next eligible Agent publication. - * @returns exact allowed routes; an empty list disables model-facing selection. + * Read a detached selection preference for the next eligible Agent publication. + * @returns the enabled state and exact allowed routes. */ - currentAllowedModels(): AllowedModelRoute[] { - return this.source().allowedModels.map(route => ({ ...route })) + current(): SubagentModelSelectionSettings { + const current = this.source() + return { + enabled: current.enabled, + allowedModels: current.allowedModels.map(route => ({ ...route })), + } + } + + private validate(value: SubagentModelSelectionSettings): void { + assertAllowedModelRoutes(value.allowedModels) + if (value.enabled && value.allowedModels.length === 0) { + throw new Error('enabled subagent model selection requires at least one allowed model') + } } } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index df8251690d..42ed6d3b7c 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -20,9 +20,10 @@ export const AllowedModelRouteSchema: z = z.object({ }) /** Route-selection authority captured by one delegation definition. */ -export type ModelSelectionPolicy = - | { readonly kind: 'unrestricted' } - | { readonly kind: 'allowlist'; readonly routes: readonly AllowedModelRoute[] } +export interface ModelSelectionPolicy { + /** Exact provider/model routes authorized for explicit selection. */ + readonly routes: readonly AllowedModelRoute[] +} /** * Stable identity for one provider/model pair. @@ -132,7 +133,7 @@ export function assertAllowedModelSelection( requested: AgentOptions | undefined, request: DelegationModelRequest, ): void { - if (policy?.kind !== 'allowlist' || !hasDelegationModelRequest(request)) return + if (policy === undefined || !hasDelegationModelRequest(request)) return const provider = requested?.provider ?? parentOptions.provider const model = requested?.model ?? parentOptions.model if (provider === undefined || model === undefined) { diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 918e3a38f6..6fe8b866e4 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -3,10 +3,13 @@ import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' +import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' /** Shared non-aborted tool signal for package-local integration tests. */ export const testToolSignal = new AbortController().signal @@ -18,17 +21,58 @@ export function fakeAgent(id = 'parent-1'): Agent { } /** Mount the real tool and service stack around one scripted subagent provider. */ -export async function setup(toolConfig: tool.Config, mockConfig: Partial = {}): Promise { +const setupAgents = new WeakMap() +let setupAgentCounter = 0 + +/** Test-only opt-in translated to the real Host setting and Session path. */ +type SetupConfig = tool.Config & { withModelSelection?: boolean } + +const TEST_ALLOWED_MODELS = [ + 'allowed-model', 'configured-model', 'current-model', 'fast-model', 'other-model', + 'parent-model', 'unlisted-model', +].flatMap(model => [ + { provider: 'alpha', model }, + { provider: 'current-provider', model }, + { provider: 'missing', model }, +]) + +export async function setup(toolConfig: SetupConfig, mockConfig: Partial = {}): Promise { const ctx = new Context() + const { withModelSelection, ...config } = toolConfig + if (withModelSelection === true) { + await ctx.plugin(SubagentModelSelectionConfig, { + enabled: true, + allowedModels: TEST_ALLOWED_MODELS, + }) + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentRuntime) + await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) + const handle = await ctx.agents.create({ + sessionId: SessionId(`model-selection-setup-${++setupAgentCounter}`), + setup: async (agentCtx) => { + await agentCtx.plugin(tool, { ...config, modelSelectionSettings: true }) + }, + }) + setupAgents.set(ctx, handle.agent) + return ctx + } await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) - await ctx.plugin(tool, toolConfig) + await ctx.plugin(tool, config) return ctx } +/** Return the real Agent created for a settings-controlled setup. */ +export function modelSelectionSetupAgent(ctx: Context): Agent { + const agent = setupAgents.get(ctx) + if (agent === undefined) throw new Error('context has no model-selection setup Agent') + return agent +} + let callCounter = 0 /** Execute the registered subagent tool through the real ToolRuntime pipeline. */ @@ -40,7 +84,7 @@ export function callSubagent( // Distinguish "no override" (use a default agent) from an explicit // `{ agent: undefined }` (test the no-agent path). Under // exactOptionalPropertyTypes the key is omitted rather than set to undefined. - const agent = 'agent' in over ? over.agent : fakeAgent() + const agent = 'agent' in over ? over.agent : setupAgents.get(ctx) ?? fakeAgent() return ctx.tools.execute({ signal: testToolSignal, callId: ToolCallId(`call-${++callCounter}`), diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index c58c775946..d1739dcce8 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { ToolCallId, @@ -57,14 +57,18 @@ class CatalogAdapter extends LlmAdapter { } } -async function setupListTool() { +async function setupListTool(routes = [ + { provider: 'alpha', model: 'fast' }, + { provider: 'alpha', model: 'plain' }, + { provider: 'beta', model: 'fast' }, + { provider: 'beta', model: 'plain' }, +]) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - const fiber = await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) - return { ctx, fiber } + registerListSubagentModels(ctx, { routes }) + return ctx } async function setupAllowedListTool() { @@ -73,7 +77,6 @@ async function setupAllowedListTool() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) registerListSubagentModels(ctx, { - kind: 'allowlist', routes: [ { provider: 'alpha', model: 'fast' }, { provider: 'alpha', model: 'unlisted' }, @@ -110,23 +113,21 @@ describe('list_subagent_models', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SubagentRuntime) - await ctx.plugin(tool, { provider: 'unused', enableModelSelection: true }) + registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] }) const result = await call(ctx, {}) expect(result.isError).toBe(true) expect(text(result)).toContain('`llm` service is unavailable') }) it('rejects two discovery-owning instances in one tool scope', async () => { - const { ctx } = await setupListTool() - await expect(ctx.plugin(tool, { - provider: 'another-unused', - toolName: 'subagent_other', - enableModelSelection: true, - }).then(() => undefined)).rejects.toThrow('tool "list_subagent_models" is already registered') + const ctx = await setupListTool() + expect(() => { + registerListSubagentModels(ctx, { routes: [{ provider: 'alpha', model: 'fast' }] }) + }).toThrow('tool "list_subagent_models" is already registered') }) it('lists registered providers and follows live registration changes', async () => { - const { ctx, fiber } = await setupListTool() + const ctx = await setupListTool() const empty = await call(ctx, {}) expect(empty.isError).toBe(false) expect(text(empty)).toBe('(no LLM providers)') @@ -140,12 +141,13 @@ describe('list_subagent_models', () => { const changed = await call(ctx, {}) expect(text(changed)).toBe('beta — BETA API') - await fiber.dispose() - expect(ctx.tools.get('list_subagent_models')).toBeUndefined() + const tools = ctx.tools + await ctx.fiber.dispose() + expect(tools.get('list_subagent_models')).toBeUndefined() }) it('lists one provider\'s advertised models without treating the catalog as a whitelist', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha' }) expect(result.isError).toBe(false) @@ -166,8 +168,21 @@ describe('list_subagent_models', () => { expect(text(denied)).toContain('is not allowed for this Session') }) + it('rejects an unauthorized provider before calling its adapter catalog', async () => { + const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }]) + const adapter = new CatalogAdapter() + const listModels = vi.spyOn(adapter, 'listModels') + ctx.llm.registerAdapter(['alpha', 'secret'], adapter) + + const result = await call(ctx, { provider: 'secret' }) + + expect(result.isError).toBe(true) + expect(text(result)).toContain('provider "secret" is not allowed for this Session') + expect(listModels).not.toHaveBeenCalled() + }) + it('renders an empty advertised model list', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool([{ provider: 'alpha', model: 'fast' }]) ctx.llm.registerAdapter(['alpha'], new CatalogAdapter(true)) const result = await call(ctx, { provider: 'alpha' }) expect(result.isError).toBe(false) @@ -175,8 +190,8 @@ describe('list_subagent_models', () => { }) it('inspects exact-model efforts, descriptions, and defaults', async () => { - const { ctx } = await setupListTool() - ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) + const ctx = await setupListTool() + ctx.llm.registerAdapter(['alpha', 'secret'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha', model: 'fast' }) expect(result.isError).toBe(false) expect(text(result)).toBe( @@ -186,7 +201,7 @@ describe('list_subagent_models', () => { }) it('renders exact models without reasoning metadata', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha', model: 'plain' }) expect(result.isError).toBe(false) @@ -196,16 +211,16 @@ describe('list_subagent_models', () => { it.each([ { args: { model: 'fast' }, expected: '`model` requires `provider`' }, { args: { provider: '' }, expected: '`provider` must be non-empty' }, - { args: { provider: 'missing' }, expected: 'available providers: (none)' }, + { args: { provider: 'missing' }, expected: 'is not allowed for this Session' }, ])('rejects incomplete or unavailable provider requests', async ({ args, expected }) => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() const result = await call(ctx, args) expect(result.isError).toBe(true) expect(text(result)).toContain(expected) }) it('rejects an empty exact model after resolving the provider', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool() ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'alpha', model: '' }) expect(result.isError).toBe(true) @@ -213,10 +228,14 @@ describe('list_subagent_models', () => { }) it('reports registered alternatives for an unavailable provider', async () => { - const { ctx } = await setupListTool() + const ctx = await setupListTool([ + { provider: 'alpha', model: 'fast' }, + { provider: 'missing', model: 'fast' }, + ]) ctx.llm.registerAdapter(['alpha'], new CatalogAdapter()) const result = await call(ctx, { provider: 'missing' }) expect(result.isError).toBe(true) expect(text(result)).toContain('available providers: alpha') + expect(text(result)).not.toContain('secret') }) }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 7cd0926bdb..b5ecac11b8 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -85,9 +85,9 @@ async function createAgent(ctx: Context, id: string, options: { describe('SubagentModelSelectionConfig', () => { it('uses the composed default without a settings provider', async () => { const ctx = new Context() - await ctx.plugin(SubagentModelSelectionConfig, { allowedModels: ALLOWED_MODELS }) + await ctx.plugin(SubagentModelSelectionConfig, { enabled: true, allowedModels: ALLOWED_MODELS }) - expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: true, allowedModels: ALLOWED_MODELS }) await ctx.fiber.dispose() }) @@ -96,13 +96,16 @@ describe('SubagentModelSelectionConfig', () => { await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) - expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual([]) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) - expect(ctx.subagentModelSelection.currentAllowedModels()).toEqual(ALLOWED_MODELS) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: true, allowedModels: ALLOWED_MODELS }) await ctx.fiber.dispose() }) - it('rejects duplicate routes and an empty durable policy', async () => { + it('rejects duplicate routes, enabled empty settings, and an empty durable policy', async () => { const ctx = new Context() await ctx.plugin(MemorySettings) await ctx.plugin(SubagentModelSelectionConfig) @@ -111,6 +114,18 @@ describe('SubagentModelSelectionConfig', () => { allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS], })).rejects.toThrow('repeats route "alpha/fast-model"') + await expect(ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: [], + })).rejects.toThrow('enabled subagent model selection requires at least one allowed model') + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: false, + allowedModels: ALLOWED_MODELS, + }) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + expect(ctx.subagentModelSelection.current()).toEqual({ enabled: false, allowedModels: [] }) + const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route') @@ -123,13 +138,16 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, disabled)).toBe(false) expect(subagentModelSelectionPolicy(disabled.session)).toBeUndefined() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const enabled = await createAgent(ctx, 'enabled') expect(subagentModelSelectionPolicy(enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) const disabledAgain = await createAgent(ctx, 'disabled-again') expect(selectable(ctx, disabledAgain)).toBe(false) expect(selectable(ctx, enabled)).toBe(true) @@ -138,7 +156,10 @@ describe('SubagentModelSelectionConfig', () => { it('rejects a forced route outside the Session policy before child creation', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const agent = await createAgent(ctx, 'enforced') const result = await ctx.tools.execute({ @@ -180,7 +201,10 @@ describe('SubagentModelSelectionConfig', () => { const disabled = await createComposed('preset-disabled') expect(selectable(ctx, disabled.agent)).toBe(false) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const enabled = await createComposed('preset-enabled') expect(selectable(ctx, enabled.agent)).toBe(true) expect(selectable(ctx, disabled.agent)).toBe(false) @@ -200,9 +224,12 @@ describe('SubagentModelSelectionConfig', () => { it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const parent = await createAgent(ctx, 'parent') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: [] }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) const child = await createAgent(ctx, 'child', { meta: { parentSession: parent.id, origin: 'subagent' }, }) @@ -220,27 +247,16 @@ describe('SubagentModelSelectionConfig', () => { expect(selectable(ctx, resumedEnabled)).toBe(true) const oldSeed = Session.create(SessionId('old-seed'), []) - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.events }) expect(selectable(ctx, resumedDisabled)).toBe(false) expect(subagentModelSelectionPolicy(resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) - it('rejects ambiguous static and settings-controlled configuration', async () => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(SubagentRuntime) - expect(() => { - tool.apply(ctx, { - provider: 'missing', - enableModelSelection: true, - modelSelectionSettings: true, - }) - }).toThrow('mutually exclusive') - await ctx.fiber.dispose() - }) - it('requires both the Host setting owner and a composition scope', async () => { const withoutSettings = new Context() await mountAgentLoopTestDependencies(withoutSettings) @@ -286,7 +302,10 @@ describe('SubagentModelSelectionConfig', () => { await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) .rejects.toThrow('must expose route fields and list_subagent_models') - await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { allowedModels: ALLOWED_MODELS }) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { + enabled: true, + allowedModels: ALLOWED_MODELS, + }) const enabled = await createAgent(ctx, 'invariant-enabled') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 710747bacc..32072573fa 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -11,7 +11,7 @@ import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { assertAllowedModelRoutes, assertAllowedModelSelection } from '../src/model-selection.ts' -import { callSubagent, setup, text } from './harness.ts' +import { callSubagent, modelSelectionSetupAgent, setup, text } from './harness.ts' const REASONING = { efforts: [ @@ -42,7 +42,6 @@ describe('dsh-tool-subagent model selection', () => { it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => { const policy = { - kind: 'allowlist' as const, routes: [{ provider: 'alpha', model: 'allowed-model' }], } const parent = { provider: 'alpha', model: 'parent-model' } @@ -81,9 +80,28 @@ describe('dsh-tool-subagent model selection', () => { ) }).toThrow('without an effective provider and model') }) - it('exposes static route fields and discovery when selection is enabled', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) - const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + + it('leaves deployment or parent defaults outside the allowlist usable when the call selects nothing', async () => { + let starts = 0 + const ctx = await setup( + { provider: 'mock', withModelSelection: true }, + { onStart: () => { starts += 1 } }, + ) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = { + provider: 'deployment-provider', + model: 'deployment-model', + } + + const result = await callSubagent(ctx, { description: 'default route', prompt: 'do it' }) + + expect(result.isError).toBe(false) + expect(starts).toBe(1) + }) + it('exposes Session-authorized route fields and discovery when selection is enabled', async () => { + const ctx = await setup({ provider: 'mock', withModelSelection: true }) + const agent = modelSelectionSetupAgent(ctx) + const schema = ctx.tools.schemas(agent).find(entry => entry.name === 'subagent')! const props = (schema.parameters as { properties?: Record }).properties ?? {} expect(Object.keys(props).sort()).toEqual([ 'description', @@ -94,13 +112,13 @@ describe('dsh-tool-subagent model selection', () => { 'run_in_background', ]) expect(schema.description).toContain('list_subagent_models') - expect(ctx.tools.get('list_subagent_models')).toBeDefined() + expect(ctx.tools.get('list_subagent_models', agent)).toBeDefined() expect(schema.description).not.toContain('alpha') const registration = ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) - const definition = ctx.tools.get('subagent') + const definition = ctx.tools.get('subagent', agent) registration.replace(['beta']) - expect(ctx.tools.get('subagent')).toBe(definition) + expect(ctx.tools.get('subagent', agent)).toBe(definition) expect(definition?.description).not.toContain('beta') }) @@ -124,7 +142,7 @@ describe('dsh-tool-subagent model selection', () => { it('rejects enabled model selection when the provider cannot apply Agent options', async () => { await expect(setup( - { provider: 'mock', enableModelSelection: true, maxDepth: 'provider-managed' }, + { provider: 'mock', withModelSelection: true, maxDepth: 'provider-managed' }, { capabilities: { agentOptions: false } }, )).rejects.toThrow('provider "mock" does not support child model selection') }) @@ -133,7 +151,7 @@ describe('dsh-tool-subagent model selection', () => { const requests: SubagentStartRequest[] = [] const ctx = await setup({ provider: 'mock', - enableModelSelection: true, + withModelSelection: true, agentOptions: { provider: 'alpha', model: 'configured-model', @@ -142,6 +160,8 @@ describe('dsh-tool-subagent model selection', () => { }, }, { onStart: (request) => { requests.push(request) } }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const selected = await callSubagent(ctx, { description: 'route work', @@ -176,38 +196,44 @@ describe('dsh-tool-subagent model selection', () => { const requests: SubagentStartRequest[] = [] const ctx = await setup({ provider: 'mock', - enableModelSelection: true, + withModelSelection: true, agentOptions: { provider: 'alpha' }, }, { onStart: (request) => { requests.push(request) } }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const result = await callSubagent(ctx, { description: 'effort work', prompt: 'do it', reasoning_effort: 'low', - }, { agent: parentWithRoute() }) + }) expect(result.isError).toBe(false) expect(requests[0]?.agentOptions).toEqual({ provider: 'alpha', reasoningEffort: 'low' }) - const inherited = await setup({ provider: 'mock', enableModelSelection: true }) + const inherited = await setup({ provider: 'mock', withModelSelection: true }) inherited.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const inheritedParent = modelSelectionSetupAgent(inherited) + ;(inheritedParent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const inheritedResult = await callSubagent(inherited, { description: 'parent effort work', prompt: 'do it', reasoning_effort: 'low', - }, { agent: parentWithRoute() }) + }) expect(inheritedResult.isError).toBe(false) }) it('inherits a parent effort only when an explicit route stays unchanged', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = parentWithRoute().options const result = await callSubagent(ctx, { description: 'same route work', prompt: 'do it', provider: 'alpha', model: 'parent-model', - }, { agent: parentWithRoute() }) + }) expect(result.isError).toBe(false) }) @@ -215,11 +241,14 @@ describe('dsh-tool-subagent model selection', () => { const requests: SubagentStartRequest[] = [] const ctx = await setup({ provider: 'mock', - enableModelSelection: true, + withModelSelection: true, agentOptions: { reasoningEffort: ReasoningEffortId('high') }, }, { onStart: (request) => { requests.push(request) } }) ctx.llm.registerAdapter(['current-provider'], new MockAdapter([], REASONING)) - const parent = parentWithRoute({ provider: 'created-provider', model: 'created-model' }) + const parent = modelSelectionSetupAgent(ctx) + ;(parent as unknown as { options: Agent['options'] }).options = { + provider: 'created-provider', model: 'created-model', + } parent.session.append('request/header', { header: { config: { provider: 'current-provider', model: 'current-model' } }, reason: 'initial', @@ -230,7 +259,7 @@ describe('dsh-tool-subagent model selection', () => { prompt: 'do it', provider: 'current-provider', model: 'current-model', - }, { agent: parent }) + }) expect(result.isError).toBe(false) expect(requests[0]?.agentOptions).toEqual({ @@ -241,7 +270,7 @@ describe('dsh-tool-subagent model selection', () => { }) it('rejects an effort without any effective route', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }) const result = await callSubagent(ctx, { description: 'missing route', prompt: 'do it', @@ -256,7 +285,7 @@ describe('dsh-tool-subagent model selection', () => { { model: 'fast-model' }, ])('rejects a partial model-facing route before child creation', async (route) => { let starts = 0 - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } }) const result = await callSubagent(ctx, { description: 'partial route', prompt: 'do it', ...route }) expect(result.isError).toBe(true) expect(text(result)).toContain('`provider` and `model` must be supplied together') @@ -268,7 +297,7 @@ describe('dsh-tool-subagent model selection', () => { { provider: 'alpha', model: '', expected: '`model` must be non-empty' }, { reasoning_effort: '', expected: '`reasoning_effort` must be non-empty' }, ])('rejects empty model-facing values', async ({ expected, ...selection }) => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }) const result = await callSubagent(ctx, { description: 'empty route', prompt: 'do it', ...selection }) expect(result.isError).toBe(true) expect(text(result)).toContain(expected) @@ -276,7 +305,7 @@ describe('dsh-tool-subagent model selection', () => { it('uses the LLM runtime for provider and reasoning-effort validation before child creation', async () => { let starts = 0 - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], REASONING)) const unsupported = await callSubagent(ctx, { @@ -351,7 +380,6 @@ describe('dsh-tool-subagent model selection', () => { await mock.mountScriptedProvider(ctx, { name: 'mock' }) await ctx.plugin(tool, { provider: 'mock', - enableModelSelection: true, agentOptions: { provider: 'alpha', model: 'fast-model', @@ -363,14 +391,6 @@ describe('dsh-tool-subagent model selection', () => { expect(configured.isError).toBe(true) expect(text(configured)).toContain('`llm` service is unavailable') - const selected = await callSubagent(ctx, { - description: 'selected route', - prompt: 'do it', - provider: 'alpha', - model: 'other-model', - }) - expect(selected.isError).toBe(true) - expect(text(selected)).toContain('`llm` service is unavailable') }) it('keeps pure inherited routing usable without an LLM service lookup', async () => { @@ -388,15 +408,15 @@ describe('dsh-tool-subagent model selection', () => { }) it('warns that changing a fork route can lose inherited-prefix reuse', async () => { - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { inheritsParentContext: true }) - const schema = ctx.tools.schemas().find(entry => entry.name === 'subagent')! + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { inheritsParentContext: true }) + const schema = ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(entry => entry.name === 'subagent')! expect(schema.description).toContain('inherits this conversation') expect(schema.description).toContain('can prevent provider-side reuse of the inherited conversation prefix') }) it('propagates an exact-route resolver failure before child creation', async () => { let starts = 0 - const ctx = await setup({ provider: 'mock', enableModelSelection: true }, { onStart: () => { starts += 1 } }) + const ctx = await setup({ provider: 'mock', withModelSelection: true }, { onStart: () => { starts += 1 } }) const adapter = new MockAdapter([]) vi.spyOn(adapter, 'resolveModel').mockRejectedValue(new Error('selected route unavailable')) ctx.llm.registerAdapter(['alpha'], adapter) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 4243581e23..cff7140a25 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -964,7 +964,10 @@ describe('dsh-tool-subagent background mode', () => { }) it('skips background startup when cancellation wins asynchronous route preflight', async () => { - const ctx = await backgroundSetup({ provider: 'mock', enableModelSelection: true }) + const ctx = await backgroundSetup({ + provider: 'mock', + agentOptions: { provider: 'alpha', model: 'selected-model' }, + }) const parent = ownerAgent(ctx, 'sess-parent') const adapter = new MockAdapter([]) let releasePreflight!: () => void @@ -979,8 +982,6 @@ describe('dsh-tool-subagent background mode', () => { const resultPromise = callSubagent(ctx, { description: 'cancelled selection', prompt: 'do it', - provider: 'alpha', - model: 'selected-model', run_in_background: true, }, { agent: parent, signal: controller.signal }) await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() }) diff --git a/packages/test-support/client-runtime/src/settings-scope.ts b/packages/test-support/client-runtime/src/settings-scope.ts index 86566bb33a..0f325a54b0 100644 --- a/packages/test-support/client-runtime/src/settings-scope.ts +++ b/packages/test-support/client-runtime/src/settings-scope.ts @@ -10,6 +10,8 @@ export interface StubSettingsScope { scope: SettingsScope /** Spy behind `scope.set`; resolves immediately. */ set: ReturnType + /** Spy behind `scope.mutate`; resolves immediately. */ + mutate: ReturnType /** Spy behind `scope.unset`; resolves immediately. */ unset: ReturnType /** @returns how many listeners are currently subscribed (disposal assertions). */ @@ -34,6 +36,7 @@ export function stubSettingsScope(): StubSettingsScope { } const listeners = new Set<() => void>() const set = vi.fn(() => Promise.resolve()) + const mutate = vi.fn(() => Promise.resolve()) const unset = vi.fn(() => Promise.resolve()) return { scope: { @@ -42,10 +45,12 @@ export function stubSettingsScope(): StubSettingsScope { listeners.add(listener) return () => { listeners.delete(listener) } }, + mutate, set, unset, }, set, + mutate, unset, listenerCount: () => listeners.size, publish: (next) => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 7194655904..2392962be1 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -241,6 +241,7 @@ export const LINK_MAP: Readonly> = { AgentHandle: 'core.md', ModelSelection: 'core.md', AllowedModelRoute: 'subagent.md', + SubagentModelSelectionSettings: 'subagent.md', AgentOptions: 'core.md', AgentStatus: 'core.md', ContentBlock: 'llm-streaming.md', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index faa9422877..6cfbc4d618 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -63,6 +63,7 @@ import type TeamService from '@deepseek-ai/dsh-experimental-agent-team' import * as ToolTeam from '@deepseek-ai/dsh-experimental-tool-agent-team' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import { registerListSubagentModels } from '../packages/subagent/tool-subagent/src/list-models.ts' import * as ToolWeb from '@deepseek-ai/dsh-tool-web' import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' @@ -467,10 +468,11 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(SubagentRuntime) await ctx.plugin(LlmRuntime) registerCatalogSubagentProvider(ctx, 'mock') - await ctx.plugin(ToolSubagent, { provider: 'mock', enableModelSelection: true }) + await ctx.plugin(ToolSubagent, { provider: 'mock' }) + registerListSubagentModels(ctx, { routes: [{ provider: 'mock', model: 'mock' }] }) }, note: - 'The registered delegation name is the load-time `toolName` config (default `subagent`); the schema above shows static model selection enabled for reference. Model selection defaults off. Web presets sample the default-off Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Explicit compositions may instead use static `enableModelSelection`. Each instance independently controls model selection, discovery ownership, and background behavior through `enableModelSelection`, `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', + 'The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`.', }, { pkg: '@deepseek-ai/dsh-tool-subagent-control', diff --git a/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl b/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl deleted file mode 100644 index 0b49c17e82..0000000000 --- a/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl +++ /dev/null @@ -1,7 +0,0 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"node -e \"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\"","description":"Wait until cancellation"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf skipped > skipped.txt","description":"Write skipped marker"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted before dispatch"}}]}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/snapshots/acp/escalation-approved/cordis.yml b/snapshots/acp/escalation-approved/cordis.yml index 0f07b6fec5..e08f3005c3 100644 --- a/snapshots/acp/escalation-approved/cordis.yml +++ b/snapshots/acp/escalation-approved/cordis.yml @@ -54,7 +54,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 diff --git a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-continuable/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-list-agents/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json index 6273e6b106..a3c5b2e531 100644 --- a/snapshots/sdk/subagent-report/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-report/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -477,7 +460,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -489,18 +472,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index ba32b06baf..9efdde3cf9 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -134,13 +134,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -191,18 +184,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** 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. */ run_in_background?: boolean; } & Record; @@ -414,7 +401,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/both-mode-turn/tool-schemas.expected.json b/snapshots/session/both-mode-turn/tool-schemas.expected.json index 5668ee9294..9893105262 100644 --- a/snapshots/session/both-mode-turn/tool-schemas.expected.json +++ b/snapshots/session/both-mode-turn/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -482,7 +465,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -494,18 +477,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/code-mode-read-image/system-prompt.expected.md b/snapshots/session/code-mode-read-image/system-prompt.expected.md index 1690867b36..3e09ce8079 100644 --- a/snapshots/session/code-mode-read-image/system-prompt.expected.md +++ b/snapshots/session/code-mode-read-image/system-prompt.expected.md @@ -136,13 +136,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -193,18 +186,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** 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. */ run_in_background?: boolean; } & Record; @@ -416,7 +403,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/code-mode-turn/system-prompt.expected.md b/snapshots/session/code-mode-turn/system-prompt.expected.md index ddfe1c8024..f297328c55 100644 --- a/snapshots/session/code-mode-turn/system-prompt.expected.md +++ b/snapshots/session/code-mode-turn/system-prompt.expected.md @@ -136,13 +136,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -193,18 +186,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** 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. */ run_in_background?: boolean; } & Record; @@ -416,7 +403,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index a0dfdc2277..843da9da21 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -301,13 +301,6 @@ interface ToolArgsMap { /** children (default) lists direct children only; descendants walks the complete tree below you. */ scope?: "children" | "descendants"; } & Record; - /** Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields. */ - list_subagent_models: { - /** Registered LLM provider id. Omit to list providers. */ - provider?: string; - /** Exact model id to inspect. Requires provider; omit to list that provider's advertised models. */ - model?: string; - } & Record; /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */ ralph: { /** The immutable completion objective for every fresh Ralph round. */ @@ -358,18 +351,12 @@ interface ToolArgsMap { /** Optional parameter of `view` command when `path` points to a file. If omitted or null, the full file is shown. If provided, the file will be shown in the indicated line number range, e.g. [11, 12] will show lines 11 and 12. Indexing at 1 to start. Setting `[start_line, -1]` shows all lines from `start_line` to the end of the file. */ view_range?: number[] | null; } & Record; - /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort. */ + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. */ subagent: { /** A short (3-5 word) description of the delegated task, for display. */ description: string; /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ prompt: string; - /** LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route. */ - provider?: string; - /** Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route. */ - model?: string; - /** Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default. */ - reasoning_effort?: string; /** 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. */ run_in_background?: boolean; } & Record; @@ -600,7 +587,6 @@ interface ToolOutputMap { parent?: string; depth?: number; })[]; - list_subagent_models: string; ralph: { runId: string; agentsStarted: number; diff --git a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json index 9faf8c3d89..3a0073f42a 100644 --- a/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json +++ b/snapshots/session/cordis-inspect-jsdoc/tool-schemas.expected.json @@ -457,23 +457,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -679,7 +662,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -691,18 +674,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json index 4f943e54bf..47769041bd 100644 --- a/snapshots/session/fs-glob-sampling/tool-schemas.expected.json +++ b/snapshots/session/fs-glob-sampling/tool-schemas.expected.json @@ -180,23 +180,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -365,7 +348,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -377,18 +360,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/lsp-definition/tool-schemas.expected.json b/snapshots/session/lsp-definition/tool-schemas.expected.json index 818a268882..05543b4012 100644 --- a/snapshots/session/lsp-definition/tool-schemas.expected.json +++ b/snapshots/session/lsp-definition/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "lsp", "description": "Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.", @@ -498,7 +481,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -510,18 +493,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/product-subagent-both/tool-schemas.expected.json b/snapshots/session/product-subagent-both/tool-schemas.expected.json index fe34e29475..e457070e01 100644 --- a/snapshots/session/product-subagent-both/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-both/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/product-subagent-codex/tool-schemas.expected.json b/snapshots/session/product-subagent-codex/tool-schemas.expected.json index 5efa018df1..bc41f13b88 100644 --- a/snapshots/session/product-subagent-codex/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-codex/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json index 6752716683..2072fb3d15 100644 --- a/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/product-subagent-result-diagnostic/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json index 7714ecf3a5..144d2309e9 100644 --- a/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json +++ b/snapshots/session/pty-tools-sandbox-backend/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/session-query-spill/tool-schemas.expected.json b/snapshots/session/session-query-spill/tool-schemas.expected.json index 62b603d80c..3e28d4cfcb 100644 --- a/snapshots/session/session-query-spill/tool-schemas.expected.json +++ b/snapshots/session/session-query-spill/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -665,7 +648,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -677,18 +660,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json index 76f213f7c8..7e4dfe696c 100644 --- a/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json +++ b/snapshots/session/subagent-child-question-rejection/tool-schemas.expected.json @@ -323,23 +323,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -524,7 +507,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -536,18 +519,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml b/snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml deleted file mode 100644 index 81eb6dfa49..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/cordis.snapshot.yml +++ /dev/null @@ -1,66 +0,0 @@ -# Keyless counterpart to subagent-configured-effort.cordis.yml: disable the -# live adapter, insert replay, and apply the configured-effort rejection patch. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - config: - runnerCommand: - - bash - - -c - - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" - - passthrough-runner - runnerFailureSignatures: - - 'passthrough-runner: profile rejected' - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - enableModelSelection: true - backgroundMode: continuable - maxDepth: 1 - agentOptions: - provider: deepseek-official - model: deepseek-v4-flash - reasoningEffort: unsupported - -# Select the recorded flash model for this composition. -- id: agent-default-model - name: '@deepseek-ai/dsh-agent-default-model' - config: - provider: deepseek-official - model: deepseek-v4-flash - -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js dshHomePath('sessions') - compression: none - -- id: agent-instructions - name: '@deepseek-ai/dsh-agent-instructions' - config: - maxBytes: 65536 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - persona: | - You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. - - Verify your work by running the code or tests. Keep answers brief and factual. - -- insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek-official - name: DeepSeek - models: - - id: deepseek-v4-flash - - id: deepseek-v4-pro diff --git a/snapshots/session/subagent-configured-effort-rejection/cordis.yml b/snapshots/session/subagent-configured-effort-rejection/cordis.yml deleted file mode 100644 index 9042fd3d91..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/cordis.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Configured-effort rejection snapshot overlay: keep one invalid configured -# effort so the tool rejects before starting a child instead of deferring the -# failure to the child agent loop. -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - enableModelSelection: true - backgroundMode: continuable - maxDepth: 1 - agentOptions: - provider: deepseek-official - model: deepseek-v4-flash - reasoningEffort: unsupported diff --git a/snapshots/session/subagent-configured-effort-rejection/replay.override.json b/snapshots/session/subagent-configured-effort-rejection/replay.override.json deleted file mode 100644 index 0ac208bb71..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/replay.override.json +++ /dev/null @@ -1,32 +0,0 @@ -[ - { - "kind": "chunks", - "chunks": [ - { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_list_child_model", "name": "list_subagent_models", "argumentsDelta": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_list_child_model", "name": "list_subagent_models", "arguments": "{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}" } }, - { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, - { "type": "finish", "reason": { "kind": "tool-calls" } } - ] - }, - { - "kind": "chunks", - "chunks": [ - { "type": "block-start", "index": 0, "blockType": "tool-call" }, - { "type": "tool-call-delta", "index": 0, "id": "call_configured_effort", "name": "subagent", "argumentsDelta": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" }, - { "type": "block-end", "index": 0, "block": { "type": "tool-call", "id": "call_configured_effort", "name": "subagent", "arguments": "{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}" } }, - { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 5 } }, - { "type": "finish", "reason": { "kind": "tool-calls" } } - ] - }, - { - "kind": "chunks", - "chunks": [ - { "type": "block-start", "index": 0, "blockType": "text" }, - { "type": "text-delta", "index": 0, "text": "CONFIGURED_EFFORT_REJECTED" }, - { "type": "block-end", "index": 0, "block": { "type": "text", "text": "CONFIGURED_EFFORT_REJECTED" } }, - { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2 } }, - { "type": "finish", "reason": { "kind": "stop" } } - ] - } -] diff --git a/snapshots/session/subagent-configured-effort-rejection/session.jsonl b/snapshots/session/subagent-configured-effort-rejection/session.jsonl deleted file mode 100644 index 4038864900..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/session.jsonl +++ /dev/null @@ -1,41 +0,0 @@ -{"type":"session","version":0,"id":"{{session:1}}","createdAt":1000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"permission/preset","data":{"preset":"danger-full-access"}} -{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} -{"type":"approval/policy","data":{"policy":"never"}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} -{"type":"turn/start","data":{"turn":1}} -{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","data":{"turn":1,"step":1}} -{"type":"user/message","data":{"content":[{"type":"text","text":"Inspect the configured child model, then attempt one subagent call so its configured reasoning effort is validated before child creation."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Inspect the configured child model,","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_list_child_model","name":"list_subagent_models","argumentsDelta":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_list_child_model","name":"list_subagent_models","arguments":"{\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_list_child_model"},"content":[{"type":"tool-result","toolCallId":"call_list_child_model","content":[{"type":"text","text":"deepseek-official/deepseek-v4-flash — deepseek-v4-flash\nReasoning efforts:\n(no advertised reasoning efforts)"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":1}} -{"type":"step/start","data":{"turn":1,"step":2}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_configured_effort","name":"subagent","argumentsDelta":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} -{"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_configured_effort","name":"subagent","arguments":"{\"description\":\"Validate configured effort\",\"prompt\":\"This child must never start.\",\"provider\":\"deepseek-official\",\"model\":\"deepseek-v4-flash\",\"run_in_background\":false}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_configured_effort"},"content":[{"type":"tool-result","toolCallId":"call_configured_effort","content":[{"type":"text","text":"Error: provider \"deepseek-official\" model \"deepseek-v4-flash\" does not support reasoning effort \"unsupported\""}],"isError":true}],"role":"user","id":"{{message:6}}"},"error":{"name":"LlmError","code":"UNSUPPORTED_REASONING_EFFORT"}},"sourceEventSeqs":[28],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":2}} -{"type":"step/start","data":{"turn":1,"step":3}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"CONFIGURED_EFFORT_REJECTED"}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"text","text":"CONFIGURED_EFFORT_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"step/end","data":{"turn":1,"step":3}} -{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml b/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml index 9e03d56d57..e46f117103 100644 --- a/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml +++ b/snapshots/session/subagent-depth-two-rejection/cordis.snapshot.yml @@ -20,7 +20,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 2 # Select the recorded flash model for this composition. diff --git a/snapshots/session/subagent-depth-two-rejection/cordis.yml b/snapshots/session/subagent-depth-two-rejection/cordis.yml index d25b48fb60..b2d4dbe039 100644 --- a/snapshots/session/subagent-depth-two-rejection/cordis.yml +++ b/snapshots/session/subagent-depth-two-rejection/cordis.yml @@ -5,6 +5,5 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 2 diff --git a/snapshots/session/text-turn/tool-schemas.expected.json b/snapshots/session/text-turn/tool-schemas.expected.json index 4922f06fdf..c93fb6d65f 100644 --- a/snapshots/session/text-turn/tool-schemas.expected.json +++ b/snapshots/session/text-turn/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/web-fetch/tool-schemas.expected.json b/snapshots/session/web-fetch/tool-schemas.expected.json index 85c8e3bf4a..ee18836349 100644 --- a/snapshots/session/web-fetch/tool-schemas.expected.json +++ b/snapshots/session/web-fetch/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." From 1ea72339fdde2ba64318131e0352fb221fecee25 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 12:00:15 +0800 Subject: [PATCH 034/130] fix(web): close model switch review gaps --- .../client/ui-settings-models/src/client/store.ts | 13 ++----------- .../ui-settings-models/tests/store.client.spec.ts | 10 +--------- .../client/ui-settings-plugins/src/client/index.ts | 3 --- 3 files changed, 3 insertions(+), 23 deletions(-) diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index d3b71ec6ea..6f664bd068 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -137,24 +137,15 @@ export class ModelsSettingsStore { private generation = 0 /** - * @param api - the page's wire faces (credentials Remote, llm reads, settings writes). + * @param api - the page's credentials Remote and LLM wire faces. * @param describeFace - the shared mirror's describe face (namespace views and writability). */ constructor( - private readonly api: ModelsWire, + private readonly api: Pick, private readonly schema: SettingsSchemaOperations, private readonly describeFace: SettingsDescribeFace, ) {} - /** - * Fold one successful settings write into the shared mirror before rejoining - * this page's rows. - * @param view - namespace view returned by the settings wire method. - */ - acceptNamespace(view: SettingsNamespaceView): void { - this.describeFace.acceptView(view) - } - /** * Refresh the whole page snapshot: the provider directory and the mirror's * settings answer in parallel, then one batched credential describe over diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index c9745db2b7..677ca14418 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -1,5 +1,5 @@ /** Page-store join: directory × namespaces × credentials, with last-good rows on failure. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -84,14 +84,6 @@ function api(overrides: { } describe('ModelsSettingsStore', () => { - it('forwards accepted writes into the shared settings mirror', () => { - const { face } = api() - const acceptView = vi.fn() - const store = new ModelsSettingsStore(face, settingsSchema, { acceptView } as never) - store.acceptNamespace(NAMESPACES[0]!) - expect(acceptView).toHaveBeenCalledWith(NAMESPACES[0]) - }) - it('joins rows with configured, removable, and credential state', async () => { const { face, mirror, seenRefs } = api() const store = new ModelsSettingsStore(face, settingsSchema, mirror) diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 6533d97fc0..a041ed1669 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -48,9 +48,6 @@ export type { export type { AgentLoopCardFace, AgentLoopCardState } from './agent-loop-card-controller.ts' export type { BashCardFace, BashCardState } from './bash-card-controller.ts' export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-controller.ts' -export type { - SubagentModelSelectionCardFace, SubagentModelSelectionCardState, -} from './subagent-model-selection-card-controller.ts' /** Dictionary namespace owned by this plugin. */ const NS = 'settings.plugins' From f2bb5cef05e7dcb582581421c67e60f997c8dce3 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 16:09:22 +0800 Subject: [PATCH 035/130] fix(snapshot): stabilize workflow prompt order --- packages/workflow/tool-workflow/tests/tool-workflow.spec.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 959a3a3c8c..5026248c40 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -379,6 +379,10 @@ describe('dsh-tool-workflow', () => { const section = sections.find(s => s.name === 'tool:orchestrate') expect(section?.text).toContain('orchestrate') expect(sections.some(s => s.name === 'tool:workflow')).toBe(false) + ctx.systemPrompt.section({ name: 'tool:cordis-order-probe', order: 115.5, text: 'Cordis' }) + expect((await ctx.systemPrompt.assemble()).sections + .filter(s => s.name === 'tool:cordis-order-probe' || s.name === 'tool:orchestrate') + .map(s => s.name)).toEqual(['tool:cordis-order-probe', 'tool:orchestrate']) await fiber.dispose() expect(ctx.tools.get('orchestrate')).toBeUndefined() // …and gone with the fiber — a reload must not leak a stale section. From 9fae988691af140d09bc7cacf48e24bc053c50bf Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 16:18:52 +0800 Subject: [PATCH 036/130] test(sdk): expect model discovery off by default --- apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 7829b67adc..7ef63f67a5 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -153,7 +153,7 @@ describe('Python SDK dsh profile keyless smoke', () => { const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] expect(modelRequests[0]?.reasoning_effort).toBe('max') expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name)).toContain('list_subagent_models') + expect(tools.map(tool => tool.function?.name)).not.toContain('list_subagent_models') child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'shutdown' })}\n`) const shutdown = await waitForLine(lines, value => value.id === 3, () => stderr) From 3a146064a4bdab9bba2ae36d7f8742053a1adc54 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Tue, 25 Aug 2026 20:05:12 +0800 Subject: [PATCH 037/130] fix: address subagent model selection review --- ...8-model-selected-subagent-routes.i18n.yaml | 4 +- ...26-08-18-model-selected-subagent-routes.md | 2 +- ...08-18-model-selected-subagent-routes.zh.md | 2 +- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 3 +- ...ser-authorized-subagent-model-routes.zh.md | 3 +- .../ui-settings-plugins/src/client/index.ts | 16 +++- ...ubagent-model-selection-card-controller.ts | 41 +++++++- .../tests/apply.client.spec.ts | 32 ++++++- .../tests/stores.client.spec.ts | 93 ++++++++++++++++++- packages/client/ui-settings/README.i18n.yaml | 4 +- packages/client/ui-settings/README.md | 2 +- packages/client/ui-settings/README.zh.md | 2 +- .../src/client/settings-contract.ts | 7 +- .../ui-settings/src/client/settings-scope.ts | 5 +- .../tests/settings-scope.client.spec.ts | 24 +++++ packages/subagent/tool-subagent/src/index.ts | 14 ++- .../src/model-selection-state.ts | 5 +- .../tool-subagent/src/model-selection.ts | 19 +++- .../tests/model-selection-settings.spec.ts | 41 ++++++++ .../tests/model-selection.spec.ts | 4 + 21 files changed, 287 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml index 878ed45e35..5e0f82335f 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.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-18-model-selected-subagent-routes.md -2026-08-18-model-selected-subagent-routes.md: 768da30d26f7daede6ed68dda72efcb4207ab60f -2026-08-18-model-selected-subagent-routes.zh.md: 9cf3033af0e143589ff6806acbb4600478e83929 +2026-08-18-model-selected-subagent-routes.md: 9cdbdbec3b79a93043fa6ae95a6a6dedf6072086 +2026-08-18-model-selected-subagent-routes.zh.md: 3b81f5417339325dd60884049a853d1094a9f1eb diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md index 768da30d26..9cdbdbec3b 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md @@ -48,7 +48,7 @@ The delegation definition is static across adapter registration and catalog chan ## Consequences -- A statically enabled delegation tool can select any live child LLM route without deployment selector configuration; disabled instances omit and reject model-facing route fields. +- A settings-enabled Session can select only its recorded exact child LLM routes; disabled Sessions omit and reject model-facing route fields. - The primary delegation-tool instance defaults selection off, exposes a Plugins-page exact-route opt-in for new Sessions, and registers `list_subagent_models` only in Sessions whose durable policy exists; discovery and explicit selection are constrained to that policy. - Shipped fork tools inherit the parent's provider and model and omit model-facing route fields so the inherited conversation prefix remains eligible for KV Cache reuse. - Omission retains configured defaults plus static provider route defaults or compatible parent inheritance; a route change without an explicit effort uses the selected model's default. diff --git a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md index 9cf3033af0..3b81f54173 100644 --- a/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.zh.md @@ -48,7 +48,7 @@ Status: implemented ## 结果 -- 静态启用的委派工具无需部署选择器配置,即可选择任意实时子级 LLM 路由;禁用的实例会省略并拒绝面向模型的路由字段。 +- settings 已启用的 Session 只能选择其记录的精确子级 LLM 路由;禁用的 Session 会省略并拒绝面向模型的路由字段。 - 主委派工具实例默认关闭选择,为新 Session 提供 Plugins 页面精确路由 opt-in,并且只在持久策略存在的 Session 中注册 `list_subagent_models`;发现与显式选择都受该策略限制。 - 随附 fork 工具会继承父级的提供方与模型,并省略面向模型的路由字段,使继承的对话前缀仍可供 KV Cache 复用。 - 省略选择时保留配置默认值,并使用静态提供方路由默认值或来自父级最新记录请求的兼容继承;改变路由但不显式指定强度时,使用所选模型的默认值。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 60d872ccda..803fcddb1f 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: af293dd2e74ea801892f2b73553cc15beb1c679e -2026-08-24-user-authorized-subagent-model-routes.zh.md: 973142f0294ad2bfc10dd5729e3a4a4992d6a84b +2026-08-24-user-authorized-subagent-model-routes.md: 3bb76eb8941dd5a7f86e95cfafe14516a4f948d7 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 5defbd0ee0921666a119eaac914580dad747868c diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index af293dd2e7..3bb76eb894 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -35,7 +35,8 @@ Model selection has no unrestricted static mode. The default-off Host setting is - New adapter registrations and newly advertised models do not expand user authorization. - Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. - The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. -- Unit coverage pins settings validation, Session sampling and inheritance, discovery intersection, executor denial, stale UI candidates, staged whole-array writes, and rejected-write draft preservation. The assembled Web scenario pins the real settings document and Plugins card flow. +- The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. ## Related decisions diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 973142f029..5defbd0ee0 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -35,7 +35,8 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` - 新适配器注册和新公布模型不会扩大用户授权。 - 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 - 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 -- 单元覆盖固定设置校验、Session 取样与继承、发现交集、执行器拒绝、UI 陈旧候选项、暂存后的整数组写入,以及写入被拒时保留草稿。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 +- 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存后的整数组写入、过期 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 ## Related decisions diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index a041ed1669..4b25ae2871 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -9,6 +9,7 @@ * settings scope, which keeps them unaware of one another and of other tabs. */ +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) @@ -53,13 +54,14 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'] /** * Mount the plugin configuration section and the cards this package ships. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { + const { api } = ctx.get('connection') as ConnectionHandle const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') @@ -79,6 +81,18 @@ export function apply(ctx: ClientContext): void { () => ctx.remote.$on('credentials/reference-updated', (ref) => { webSearch.refreshCredential(ref) }), 'ui-settings-plugins: credential invalidations', ) + ctx.effect( + () => ctx.remote.$on('llm/adapters-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent adapter invalidations', + ) + ctx.effect( + () => ctx.remote.$on('settings/document-updated', () => { subagentModelSelection.refreshCatalog() }), + 'ui-settings-plugins: subagent settings invalidations', + ) + ctx.effect( + () => ctx.on('connection/reset', () => { subagentModelSelection.resetCatalog() }), + 'ui-settings-plugins: subagent connection generation', + ) ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') // The shared SettingsScope mirror updates after document commits and reconnects. diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index b2f24d4b8e..f9a86396a8 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -134,6 +134,7 @@ export class SubagentModelSelectionCardController { private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' private draftEnabled: boolean | undefined private draftSelected: Set | undefined + private draftRevision: number | undefined private saving = false private saved = false private failed = false @@ -153,6 +154,11 @@ export class SubagentModelSelectionCardController { ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { + if (!this.saving && this.draftSelected !== undefined + && this.scope.getSnapshot().revision !== this.draftRevision) { + this.saved = false + this.failed = true + } if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() }) @@ -199,8 +205,12 @@ export class SubagentModelSelectionCardController { } private beginDraft(): Set { - this.draftEnabled ??= this.currentEnabled() - this.draftSelected ??= new Set(this.currentRoutes().map(subagentModelKey)) + if (this.draftSelected === undefined) { + const snapshot = this.scope.getSnapshot() + this.draftEnabled = snapshot.value?.enabled ?? false + this.draftSelected = new Set(snapshot.value?.allowedModels.map(subagentModelKey) ?? []) + this.draftRevision = snapshot.revision + } return this.draftSelected } @@ -230,6 +240,7 @@ export class SubagentModelSelectionCardController { if (this.saving) return this.draftEnabled = undefined this.draftSelected = undefined + this.draftRevision = undefined this.saved = false this.failed = false this.publish() @@ -252,6 +263,12 @@ export class SubagentModelSelectionCardController { if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) || (desiredEnabled && desired.length === 0)) return + if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) { + this.saved = false + this.failed = true + this.publish() + return + } const generation = this.saveGeneration this.saving = true this.saved = false @@ -260,7 +277,7 @@ export class SubagentModelSelectionCardController { await this.scope.mutate([ { op: 'set', path: ['enabled'], value: desiredEnabled }, { op: 'set', path: ['allowedModels'], value: desired }, - ]) + ], this.draftRevision) if (generation !== this.saveGeneration) return const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false @@ -269,10 +286,28 @@ export class SubagentModelSelectionCardController { if (landed) { this.draftEnabled = undefined this.draftSelected = undefined + this.draftRevision = undefined } this.publish() } + /** Invalidate and reload model candidates after a Host model input changes. */ + refreshCatalog(): void { + if (this.disposed) return + this.catalogGeneration += 1 + this.catalogStatus = 'idle' + this.catalogFailures = [] + if (this.enabled()) void this.loadCatalog() + else this.publish() + } + + /** Clear Host-specific candidates and reload after reconnecting. */ + resetCatalog(): void { + if (this.disposed) return + this.catalogGroups = [] + this.refreshCatalog() + } + private async loadCatalog(): Promise { if (this.disposed || this.catalogStatus === 'loading') return const generation = this.catalogGeneration diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index a764446096..cf237667fc 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -11,6 +11,7 @@ import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings-plugins/clien import type { ConfigurablePluginsTabFace, PluginsSettingsSectionInjected, } from '@deepseek-ai/dsh-client-ui-settings-plugins/client' +import { SubagentModelSelectionCardController } from '../src/client/subagent-model-selection-card-controller.ts' // These specs assert the shipped Chinese copy. The lane has no jsdom `window`, // so browser-language detection never runs and a fresh LocaleRuntime opens on @@ -27,6 +28,9 @@ async function bench(served?: string[]) { locale.setLocale('zh') ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) + const models = vi.fn(() => Promise.resolve({ + rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } }, + })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } : { @@ -43,9 +47,14 @@ async function bench(served?: string[]) { credentials: { describe: describeCredentials, set: vi.fn() }, settings: { describe: describeSettings }, }) - ctx.provide('connection', { isLoopback: true, api: {} } as never) + ctx.provide('connection', { + isLoopback: true, + api: { llm: { models } }, + } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() - return { ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, remote } + return { + ctx, slots: ctx.get('slots') as SlotRegistry, describeCredentials, describeSettings, models, remote, + } } function declareRoot(slots: SlotRegistry): () => void { @@ -57,7 +66,7 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'remote', 'remote.credentials', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']) }) it('registers one Plugins section and declares the tab and card slots', async () => { @@ -176,6 +185,23 @@ describe('ui-settings-plugins apply', () => { await vi.waitFor(() => { expect(describeCredentials).toHaveBeenCalledTimes(1) }) }) + it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { + const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetCatalog') + const { ctx, slots, remote } = await bench(['subagent-model-selection']) + declareRoot(slots) + await ctx.plugin({ inject: [...inject], apply }).await() + refresh.mockClear() + reset.mockClear() + + remote.emit('llm/adapters-updated', []) + expect(refresh).toHaveBeenCalledTimes(1) + remote.emit('settings/document-updated', ['llm-deepseek', 1]) + expect(refresh).toHaveBeenCalledTimes(2) + ctx.emit('connection/reset') + expect(reset).toHaveBeenCalledTimes(1) + }) + it('ignores a credential change for a reference no card watches', async () => { const { ctx, slots, describeCredentials, remote } = await bench() declareRoot(slots) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 853bb502c9..4b1a8317e8 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -456,7 +456,10 @@ describe('SubagentModelSelectionCardController', () => { groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) - host.publish({ status: 'ready', writable: true, value: { enabled: false, allowedModels: [] }, user: {} }) + host.publish({ + status: 'ready', writable: true, revision: 3, + value: { enabled: false, allowedModels: [] }, user: {}, + }) const face = controller.inject() expect(face.hooks.subagentModelSelectionCard.getSnapshot().enabled).toBe(false) @@ -470,7 +473,7 @@ describe('SubagentModelSelectionCardController', () => { expect(host.mutate).toHaveBeenCalledWith([ { op: 'set', path: ['enabled'], value: true }, { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, - ]) + ], 3) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ @@ -482,6 +485,19 @@ describe('SubagentModelSelectionCardController', () => { }) }) + it('starts an empty draft when a ready test scope has no decoded value', () => { + const host = stubSettingsScope() + const controller = new SubagentModelSelectionCardController(host.scope, modelsApi().api) + host.publish({ status: 'ready', writable: true, revision: 0, value: undefined }) + const face = controller.inject() + + face.toggleEnabled() + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + enabled: true, dirty: true, invalid: true, + }) + }) + it('keeps the Host value and reports a rejected write', async () => { const host = stubSettingsScope() const models = modelsApi({ @@ -517,7 +533,7 @@ describe('SubagentModelSelectionCardController', () => { }) const controller = new SubagentModelSelectionCardController(host.scope, models.api) host.publish({ - status: 'ready', writable: true, + status: 'ready', writable: true, revision: 5, value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, }) const face = controller.inject() @@ -541,7 +557,7 @@ describe('SubagentModelSelectionCardController', () => { const host = stubSettingsScope() acceptWrites(host) host.publish({ - status: 'ready', writable: true, + status: 'ready', writable: true, revision: 5, value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, user: {}, }) const models = modelsApi({ @@ -557,7 +573,7 @@ describe('SubagentModelSelectionCardController', () => { expect(host.mutate).toHaveBeenCalledWith([ { op: 'set', path: ['enabled'], value: false }, { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, - ]) + ], 5) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ enabled: false, dirty: false, saved: true, @@ -578,6 +594,71 @@ describe('SubagentModelSelectionCardController', () => { await vi.waitFor(() => { expect(models.models).toHaveBeenCalledTimes(2) }) }) + it('rejects a draft after the Host revision changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha API', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { + expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) + }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) + face.save() + await Promise.resolve() + + expect(host.mutate).not.toHaveBeenCalled() + face.discard() + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + failed: false, dirty: false, enabled: true, + }) + }) + + it('reloads the model catalog after invalidation', async () => { + const host = stubSettingsScope() + host.publish({ + status: 'ready', writable: true, revision: 1, + value: { enabled: true, allowedModels: [] }, user: {}, + }) + const models = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'catalog-1', + result: { ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + } }, + }) + .mockResolvedValueOnce({ + rpcId: 'catalog-2', + result: { ok: true, value: { + groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }], + failures: [], + } }, + }) + const controller = new SubagentModelSelectionCardController( + host.scope, { llm: { models } } as never, + ) + const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) + + controller.refreshCatalog() + + await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('beta') }) + expect(models).toHaveBeenCalledTimes(2) + }) + it('suppresses duplicate actions and late save settlements', async () => { const host = stubSettingsScope() const catalog = modelsApi({ @@ -658,6 +739,8 @@ describe('SubagentModelSelectionCardController', () => { expect(host.mutate).not.toHaveBeenCalled() controller.dispose() + controller.refreshCatalog() + controller.resetCatalog() face.toggleEnabled() face.retryCatalog() face.save() diff --git a/packages/client/ui-settings/README.i18n.yaml b/packages/client/ui-settings/README.i18n.yaml index 06c0fe9604..9a230d0026 100644 --- a/packages/client/ui-settings/README.i18n.yaml +++ b/packages/client/ui-settings/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-settings/README.md -README.md: beda3aec1750da77af86cebdf658bde8e3834a46 -README.zh.md: 8d4fb0983c8a0ba627411fd3b653114625530639 +README.md: 3e4970bff9784a80716a073bf6d7f9f3e62889e5 +README.zh.md: a527dfe21a5c756183ffb4022ea0a8f3290f9dc5 diff --git a/packages/client/ui-settings/README.md b/packages/client/ui-settings/README.md index beda3aec17..3e4970bff9 100644 --- a/packages/client/ui-settings/README.md +++ b/packages/client/ui-settings/README.md @@ -29,7 +29,7 @@ Feature plugins use this package to store and edit their preferences without re- ### Binding a namespace -A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. +A feature calls `ctx.settingsScope.bind(spec)` with a per-namespace spec and gets a scope derived from the shared document mirror. The scope snapshot carries the resolved section, composition `base`, raw `user`, revision, writability, and host/memory mode; a field is overridden when it is present in `user`, even when its value equals `base`, and `unset` clears that override. Writes go through the scope: `set` and `unset` submit one operation, while `mutate` submits several ordered operations atomically. Each write is fenced by the namespace revision as `expectedRevision`, so a concurrent write from another surface is refused instead of silently overwritten. A staged editor can supply the revision where its draft began as a fixed fence; otherwise the scope uses the latest queued or mirrored revision. ### Filling the settings slots diff --git a/packages/client/ui-settings/README.zh.md b/packages/client/ui-settings/README.zh.md index 8d4fb0983c..a527dfe21a 100644 --- a/packages/client/ui-settings/README.zh.md +++ b/packages/client/ui-settings/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 绑定命名空间 -功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。 +功能调用 `ctx.settingsScope.bind(spec)` 并传入按命名空间的 spec,得到一个由共享文档镜像派生的 scope。scope 快照携带解析后的分区、组合 `base`、原始 `user`、revision、可写性以及 host/内存模式;字段只要出现在 `user` 中即视为覆盖,即使其值与 `base` 相等,`unset` 会清除该覆盖。写入经 scope 进行:`set` 与 `unset` 提交一个操作,`mutate` 则原子提交多个有序操作。每次写入都以命名空间 revision 作为 `expectedRevision` 围栏,因此来自另一界面的并发写入会被拒绝,而不是被静默覆盖。暂存编辑器可以把开始草拟时读取的 revision 作为固定围栏传入;否则 scope 使用最新排队或镜像 revision。 ### 填充设置 slot diff --git a/packages/client/ui-settings/src/client/settings-contract.ts b/packages/client/ui-settings/src/client/settings-contract.ts index 05b459c7a5..dd06600781 100644 --- a/packages/client/ui-settings/src/client/settings-contract.ts +++ b/packages/client/ui-settings/src/client/settings-contract.ts @@ -62,11 +62,14 @@ export interface SettingsScope { subscribe(listener: () => void): () => void /** * Queue one atomic namespace mutation. All operations share one revision - * fence, Host validation, persistence decision, and recovery read. + * fence, Host validation, persistence decision, and recovery read. Supplying + * `expectedRevision` preserves an earlier read as the fence instead of using + * the latest queued or mirrored revision. * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. * @returns settlement after the mutation and any latest-write recovery read. */ - mutate(ops: readonly SettingsPathOpView[]): Promise + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise /** * Queue one field write. Rapid writes preserve mutation order, each carries * the latest known namespace revision, and only the latest settlement may diff --git a/packages/client/ui-settings/src/client/settings-scope.ts b/packages/client/ui-settings/src/client/settings-scope.ts index 276adebb46..2d728e5154 100644 --- a/packages/client/ui-settings/src/client/settings-scope.ts +++ b/packages/client/ui-settings/src/client/settings-scope.ts @@ -120,13 +120,14 @@ export class SettingsScopeController implements SettingsScope { /** * Queue one atomic namespace mutation; see {@link SettingsScope.mutate}. * @param ops - ordered field operations copied when queued. + * @param expectedRevision - optional fixed revision read by the domain editor. * @returns settlement after the mutation and any latest-write recovery read. */ - mutate(ops: readonly SettingsPathOpView[]): Promise { + mutate(ops: readonly SettingsPathOpView[], expectedRevision?: number): Promise { const ownedOps = structuredClone(ops) as SettingsPathOpView[] const generation = ++this.writeGeneration return this.enqueue(async () => { - const revision = this.pendingRevision ?? this.getSnapshot().revision + const revision = expectedRevision ?? this.pendingRevision ?? this.getSnapshot().revision let response: Awaited> try { response = await this.api.settings.mutate(this.spec.namespace, ownedOps, revision) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index 883beee615..fe0d4013ee 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -201,6 +201,30 @@ describe('SettingsScopeController', () => { ) }) + it('preserves an editor-owned revision fence behind earlier queued writes', async () => { + const first = deferred>() + const describeCall = vi.fn() + .mockResolvedValueOnce(described({ preference: 'system' }, 7)) + .mockResolvedValueOnce(described({ preference: 'dark' }, 8)) + const mutate = vi.fn() + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce(rejected()) + const { mirror, scope } = derivedScope({ describe: describeCall, mutate }) + await mirror.load() + + const earlier = scope.set('preference', 'dark') + const fenced = scope.mutate([{ op: 'set', path: ['preference'], value: 'light' }], 7) + first.resolve(ok(view({ preference: 'dark' }, 8))) + await Promise.all([earlier, fenced]) + + expect(mutate).toHaveBeenNthCalledWith(2, { + ns: 'ui-test', + ops: [{ op: 'set', path: ['preference'], value: 'light' }], + expectedRevision: 7, + }) + expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 }) + }) + it('folds the latest write answer into the mirror so a sibling scope sees it', async () => { const describeCall = vi.fn().mockResolvedValueOnce(described({ preference: 'system' }, 4)) const mutate = vi.fn().mockResolvedValueOnce(ok(view({ preference: 'dark' }, 5))) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 30a5f148a1..3460ac3529 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -651,11 +651,15 @@ export function apply(ctx: Context, config: Config): void { // Reserve before the injected fiber runs: tool registration emits // `tools/change` synchronously, which re-enters the reconciliation below. installing.add(candidate) - const policy = selectForAgent(candidate) - const fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { - install(runtimeCtx, policy) - }) - installing.delete(candidate) + let fiber: ReturnType + try { + const policy = selectForAgent(candidate) + fiber = candidate.ctx.inject(['tools', 'subagents', 'systemPrompt'], (runtimeCtx) => { + install(runtimeCtx, policy) + }) + } finally { + installing.delete(candidate) + } scopedInstalls.set(candidate, fiber) } const removeScoped = (candidate: Agent): void => { diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index b729d6d10b..5fc6342709 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -26,8 +26,9 @@ declare module '@deepseek-ai/dsh-session/types' { export function subagentModelSelectionPolicy(session: Session): AllowedModelRoute[] | undefined { const event = session.events.find(candidate => candidate.type === 'subagent/model-selection-policy') if (event?.type !== 'subagent/model-selection-policy') return undefined - const routes = event.data.allowedModels.map(route => ({ ...route })) - assertAllowedModelRoutes(routes) + const { allowedModels } = event.data + assertAllowedModelRoutes(allowedModels) + const routes = allowedModels.map(route => ({ ...route })) if (routes.length === 0) throw new Error('subagent/model-selection-policy requires at least one route') return routes } diff --git a/packages/subagent/tool-subagent/src/model-selection.ts b/packages/subagent/tool-subagent/src/model-selection.ts index 42ed6d3b7c..5f30b71742 100644 --- a/packages/subagent/tool-subagent/src/model-selection.ts +++ b/packages/subagent/tool-subagent/src/model-selection.ts @@ -35,15 +35,24 @@ export function modelRouteKey(route: AllowedModelRoute): string { } /** - * Reject malformed or duplicate route policy entries at a configuration boundary. - * @param routes - Exact routes to validate. + * Reject malformed or duplicate route policy entries at a durable or configuration boundary. + * @param routes - Candidate exact routes to validate. + * @returns an assertion that the candidate is a validated exact-route array. */ -export function assertAllowedModelRoutes(routes: readonly AllowedModelRoute[]): void { +export function assertAllowedModelRoutes(routes: unknown): asserts routes is readonly AllowedModelRoute[] { + if (!Array.isArray(routes)) { + throw new Error('subagent model selection requires an array of routes') + } const seen = new Set() - for (const route of routes) { - if (route.provider.length === 0 || route.model.length === 0) { + const candidates: readonly unknown[] = routes + for (const candidate of candidates) { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate) + || !('provider' in candidate) || typeof candidate.provider !== 'string' + || !('model' in candidate) || typeof candidate.model !== 'string' + || candidate.provider.length === 0 || candidate.model.length === 0) { throw new Error('subagent model selection requires non-empty provider and model ids') } + const route = { provider: candidate.provider, model: candidate.model } const key = modelRouteKey(route) if (seen.has(key)) { throw new Error(`subagent model selection repeats route "${route.provider}/${route.model}"`) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index b5ecac11b8..4078a828b7 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -129,6 +129,13 @@ describe('SubagentModelSelectionConfig', () => { const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) expect(() => subagentModelSelectionPolicy(invalid)).toThrow('requires at least one route') + + const malformed = Session.create(SessionId('malformed-policy')) + malformed.append('subagent/model-selection-policy', { + allowedModels: [{ provider: 1, model: 'fast-model' }], + } as never) + expect(() => subagentModelSelectionPolicy(malformed)) + .toThrow('requires non-empty provider and model ids') await ctx.fiber.dispose() }) @@ -222,6 +229,40 @@ describe('SubagentModelSelectionConfig', () => { await ctx.fiber.dispose() }) + it('releases a shared-preset installation reservation after policy selection fails', async () => { + const ctx = await boot() + const preset = createScope(ctx, { preset: 'standard' }) + const other = createScope(ctx, { preset: 'minimal' }) + await preset.ctx.plugin(tool, { + provider: 'spawn', + modelSelectionSettings: true, + backgroundMode: 'continuable', + }) + let binding: ReturnType | undefined + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-policy-retry'), + setup: (agentCtx) => { + binding = bindScopeParent(scopeOf(agentCtx)!, scopeOf(preset.ctx)!) + }, + }) + expect(selectable(ctx, handle.agent)).toBe(false) + + binding!.rebind(scopeOf(other.ctx)!) + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + binding!.rebind(scopeOf(preset.ctx)!) + vi.spyOn(ctx.subagentModelSelection, 'current') + .mockImplementationOnce(() => { throw new Error('transient settings read') }) + .mockReturnValue({ enabled: true, allowedModels: ALLOWED_MODELS }) + + expect(() => { ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') }) + .toThrow('transient settings read') + ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') + await vi.waitFor(() => { expect(selectable(ctx, handle.agent)).toBe(true) }) + + await handle.dispose() + await ctx.fiber.dispose() + }) + it('inherits the parent decision and preserves seeded decisions across composition', async () => { const ctx = await boot() await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 32072573fa..9137e2a9dd 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -38,6 +38,10 @@ describe('dsh-tool-subagent model selection', () => { .toThrow('requires non-empty provider and model ids') expect(() => { assertAllowedModelRoutes([{ provider: 'provider', model: '' }]) }) .toThrow('requires non-empty provider and model ids') + expect(() => { assertAllowedModelRoutes({ provider: 'provider', model: 'model' }) }) + .toThrow('requires an array of routes') + expect(() => { assertAllowedModelRoutes([{ provider: 1, model: 'model' }]) }) + .toThrow('requires non-empty provider and model ids') }) it('allows pure inheritance but rejects explicit values outside a Session allowlist', () => { From a130273434c5b992bd070819996df09efb173768 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 11:44:54 +0800 Subject: [PATCH 038/130] test(subagent): type provider route defaults fixture --- packages/subagent/tool-subagent/tests/scripted-provider.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index a946c6f6fe..012ef669c6 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -34,6 +34,8 @@ export interface Config { capabilities?: Partial /** Whether tool descriptions say the child inherits completed turns. */ inheritsParentContext?: boolean + /** Provider-owned child route defaults. */ + agentRouteDefaults?: Readonly<{ provider: string; model: string }> /** Structured value returned when the request asks for one. */ structured?: unknown /** Observes each start; the child's result additionally waits for the returned promise. */ @@ -110,7 +112,10 @@ export function mountScriptedProvider(ctx: Context, config: Config) { name: 'scripted-subagent-provider', inject: ['subagents'], apply(pluginCtx: Context): void { - pluginCtx.subagents.registerProvider(new ScriptedSubagentProvider(config.name, config)) + const provider = new ScriptedSubagentProvider(config.name, config) + pluginCtx.subagents.registerProvider(config.agentRouteDefaults === undefined + ? provider + : Object.assign(provider, { agentRouteDefaults: config.agentRouteDefaults })) }, }) } From 0b2f476071ebeddd8836884e63af299152d7064b Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 11:45:15 +0800 Subject: [PATCH 039/130] fix(ui-settings-plugins): align Subagent configuration card --- .../plugin-config/section.expected.md | 4 +- apps/web/tests/plugin-config.e2e.ts | 13 ++-- .../SubagentModelSelectionCard.module.css | 25 +++++-- .../src/client/SubagentModelSelectionCard.tsx | 74 ++++++++++++++----- .../ui-settings-plugins/src/client/locales.ts | 53 ++++++------- ...ubagent-model-selection-card-controller.ts | 26 ++----- .../tests/section.client.spec.tsx | 11 ++- .../tests/stores.client.spec.ts | 5 +- 8 files changed, 124 insertions(+), 87 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 39cfa33d85..1a95e7032d 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -25,8 +25,8 @@ - tabpanel "插件配置": - list: - listitem: - - 'button "展开设置: Subagent 自选模型"': - - text: Subagent 自选模型 选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。 + - 'button "展开设置: Subagent"': + - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 - img - listitem: - 'button "展开设置: 终端"': diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index edf3b1e98f..e3b34776bb 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -77,8 +77,8 @@ describe('web e2e: plugin configuration section', () => { // Every card the shipped web composition exposes: subagent selection, the // shell executor, the agent loop, and the DeepSeek search provider. - await dialog.getByText('Subagent 自选模型', { exact: true }).waitFor({ timeout: 10_000 }) - expect(await dialog.getByRole('button', { name: '展开设置: Subagent 自选模型' }).count()).toBe(1) + await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByText('Agent 循环', { exact: true }).count()).toBe(1) expect(await dialog.getByText('网页搜索', { exact: true }).count()).toBe(1) @@ -93,11 +93,11 @@ describe('web e2e: plugin configuration section', () => { it('persists selected adapter routes as the subagent model allowlist', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-subagent-model-selection')) const dialog = await openPlugins() - await dialog.getByText('Subagent 自选模型', { exact: true }).click() - const toggle = dialog.getByRole('switch', { name: '允许 subagent 自选模型' }) + await dialog.getByText('Subagent', { exact: true }).click() + const toggle = dialog.getByRole('switch', { name: '允许 Agent 为 Subagent 选择模型' }) await toggle.click() - const models = dialog.getByRole('group', { name: '允许的模型' }) + const models = dialog.getByRole('group', { name: 'Agent 可选择的模型' }) await models.waitFor({ timeout: 10_000 }) const firstModel = models.getByRole('checkbox').first() await firstModel.check() @@ -110,7 +110,8 @@ describe('web e2e: plugin configuration section', () => { expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') - expect(await dialog.getByRole('status').textContent()).toBe('已保存,新会话将使用此设置。') + await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) + expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) await toggle.click() await dialog.getByRole('button', { name: '保存', exact: true }).click() diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 075564224a..c71952a0bd 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -54,8 +54,7 @@ .hint, .notice, -.invalid, -.status { +.invalid { margin: 0; font-size: 12px; line-height: 1.5; @@ -70,10 +69,6 @@ color: var(--dsw-alias-label-error); } -.status { - color: var(--dsw-alias-state-success-primary); -} - .catalogError { display: flex; align-items: center; @@ -109,6 +104,24 @@ color: var(--dsw-alias-label-secondary); } +.modelGroup { + display: grid; + gap: 6px; +} + +.modelGroup + .modelGroup { + margin-top: 4px; + padding-top: 10px; + border-top: 1px solid var(--dsw-alias-border-l3); +} + +.providerName { + padding: 0 6px; + font-size: 11px; + font-weight: 500; + color: var(--dsw-alias-label-tertiary); +} + .model { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index 0a8ce094fd..962dfdb41c 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -2,7 +2,10 @@ import clsx from 'clsx' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import type { SubagentModelSelectionCardFace } from './subagent-model-selection-card-controller.ts' +import type { + SubagentModelCandidate, + SubagentModelSelectionCardFace, +} from './subagent-model-selection-card-controller.ts' import type {} from './slot-contract.ts' import { PluginCard } from './PluginCard.tsx' import css from './SubagentModelSelectionCard.module.css' @@ -21,6 +24,43 @@ export type SubagentModelSelectionCardProps = export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProps) { const { t } = props const state = props.useSubagentModelSelectionCard(snapshot => snapshot) + const availableGroups = new Map() + const unavailable: SubagentModelCandidate[] = [] + for (const candidate of state.candidates) { + if (!candidate.available) { + unavailable.push(candidate) + continue + } + const group = availableGroups.get(candidate.provider) + if (group === undefined) { + availableGroups.set(candidate.provider, { + providerName: candidate.providerName, + candidates: [candidate], + }) + } else { + group.candidates.push(candidate) + } + } + const renderCandidate = (candidate: SubagentModelCandidate) => ( + + ) return ( ) : null} - {state.catalogFailures.length > 0 + {state.catalogPartial ?

    {t('subagentModelSelectionPartial')}

    : null} {state.candidates.length > 0 ? (
    {t('subagentModelSelectionAllowed')} - {state.candidates.map(candidate => ( - + {[...availableGroups].map(([provider, group]) => ( +
    +
    {group.providerName}
    + {group.candidates.map(renderCandidate)} +
    ))} + {unavailable.length > 0 + ? ( +
    +
    {t('subagentModelSelectionUnavailableGroup')}
    + {unavailable.map(renderCandidate)} +
    + ) + : null}
    ) : state.catalogStatus === 'ready' @@ -94,7 +131,6 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
    ) :

    {t('subagentModelSelectionOff')}

    } - {state.saved ?

    {t('subagentModelSelectionSaved')}

    : null} ) } diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 24a3424a01..018d87d47d 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -14,8 +14,9 @@ export type PluginsSettingsLocaleKey = | 'subagentModelSelectionTitle' | 'subagentModelSelectionDescription' | 'subagentModelSelectionToggle' | 'subagentModelSelectionChoose' | 'subagentModelSelectionAllowed' | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' - | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionEmpty' - | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' | 'subagentModelSelectionSaved' + | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' + | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' /** English copy. */ export const en: Record = { @@ -56,20 +57,20 @@ export const en: Record = { webSearchBaseUrlHint: 'Leave blank to use the provider default.', webSearchMaxUses: 'Max searches per request', webSearchMaxUsesHint: 'How many times one request may search before it must answer.', - subagentModelSelectionTitle: 'Subagent model selection', - subagentModelSelectionDescription: 'Choose which child models new sessions may select. Running sessions do not change.', - subagentModelSelectionToggle: 'Allow subagents to choose models', - subagentModelSelectionChoose: 'Select at least one model. Only these adapter routes appear in subagent discovery.', - subagentModelSelectionAllowed: 'Allowed models', - subagentModelSelectionLoading: 'Loading adapter models…', - subagentModelSelectionLoadFailed: 'Adapter models could not be loaded.', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: 'Control which models agents may choose for subagents.', + subagentModelSelectionToggle: 'Allow agents to choose models for subagents', + subagentModelSelectionChoose: 'When enabled, agents can choose a provider, model, and reasoning effort for each subagent from the authorized models below. Applies only to new sessions.', + subagentModelSelectionAllowed: 'Models agents may choose', + subagentModelSelectionLoading: 'Loading models…', + subagentModelSelectionLoadFailed: 'Models could not be loaded.', subagentModelSelectionRetry: 'Retry', - subagentModelSelectionPartial: 'Some providers could not list their models; stored choices remain removable.', - subagentModelSelectionUnavailable: 'Unavailable', - subagentModelSelectionEmpty: 'No adapter currently advertises a model.', + subagentModelSelectionPartial: 'Some model providers could not be loaded; saved choices remain removable.', + subagentModelSelectionUnavailable: 'Currently unavailable', + subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', + subagentModelSelectionEmpty: 'No model provider currently advertises a model.', subagentModelSelectionRequired: 'Select at least one model before saving.', - subagentModelSelectionOff: 'New sessions inherit the configured or parent model without choosing another route.', - subagentModelSelectionSaved: 'Saved. New sessions use this setting.', + subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } /** Simplified Chinese copy. */ @@ -111,18 +112,18 @@ export const zh: Record = { webSearchBaseUrlHint: '留空则使用提供方默认地址。', webSearchMaxUses: '单次请求最多搜索次数', webSearchMaxUsesHint: '一次请求在必须作答前最多可以搜索多少次。', - subagentModelSelectionTitle: 'Subagent 自选模型', - subagentModelSelectionDescription: '选择新会话允许为 subagent 自选的模型。运行中的会话不会改变。', - subagentModelSelectionToggle: '允许 subagent 自选模型', - subagentModelSelectionChoose: '请至少选择一个模型。Subagent 发现工具只会列出这些 adapter 路由。', - subagentModelSelectionAllowed: '允许的模型', - subagentModelSelectionLoading: '正在加载 adapter 模型…', - subagentModelSelectionLoadFailed: '无法加载 adapter 模型。', + subagentModelSelectionTitle: 'Subagent', + subagentModelSelectionDescription: '控制 Agent 为 Subagent 选择模型的权限。', + subagentModelSelectionToggle: '允许 Agent 为 Subagent 选择模型', + subagentModelSelectionChoose: '开启后,Agent 可以从下方授权模型中,为每个 Subagent 选择提供方、模型和推理强度。仅影响新会话。', + subagentModelSelectionAllowed: 'Agent 可选择的模型', + subagentModelSelectionLoading: '正在加载模型…', + subagentModelSelectionLoadFailed: '无法加载模型。', subagentModelSelectionRetry: '重试', - subagentModelSelectionPartial: '部分提供方无法列出模型;仍可移除已保存的选项。', - subagentModelSelectionUnavailable: '不可用', - subagentModelSelectionEmpty: '当前没有 adapter 公布模型。', + subagentModelSelectionPartial: '部分模型提供方暂时无法加载;已保存的选择仍可移除。', + subagentModelSelectionUnavailable: '当前不可用', + subagentModelSelectionUnavailableGroup: '已保存但当前不可用', + subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', subagentModelSelectionRequired: '保存前请至少选择一个模型。', - subagentModelSelectionOff: '新会话会使用配置值或继承父 Agent 模型,不会自主选择其他路由。', - subagentModelSelectionSaved: '已保存,新会话将使用此设置。', + subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index f9a86396a8..8af7361f13 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -2,7 +2,6 @@ import type { IApiClient, - ModelCatalogFailure, ModelProviderGroup, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -48,10 +47,8 @@ export interface SubagentModelSelectionCardState extends CardShell { candidates: readonly SubagentModelCandidate[] /** Adapter-directory request state. */ catalogStatus: 'idle' | 'loading' | 'ready' | 'error' - /** Provider-local failures that did not block other candidates. */ - catalogFailures: readonly ModelCatalogFailure[] - /** Whether the latest save landed. */ - saved: boolean + /** Whether any provider-local catalog request failed. */ + catalogPartial: boolean } /** Registration-side face for the subagent model-selection card. */ @@ -130,13 +127,12 @@ function sameRoutes(left: readonly AllowedSubagentModel[], right: readonly Allow /** Bridges one settings scope and the live adapter directory onto a staged card. */ export class SubagentModelSelectionCardController { private catalogGroups: readonly ModelProviderGroup[] = [] - private catalogFailures: readonly ModelCatalogFailure[] = [] + private catalogPartial = false private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' private draftEnabled: boolean | undefined private draftSelected: Set | undefined private draftRevision: number | undefined private saving = false - private saved = false private failed = false private disposed = false private saveGeneration = 0 @@ -156,7 +152,6 @@ export class SubagentModelSelectionCardController { this.unsubscribe = scope.subscribe(() => { if (!this.saving && this.draftSelected !== undefined && this.scope.getSnapshot().revision !== this.draftRevision) { - this.saved = false this.failed = true } if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() @@ -219,7 +214,6 @@ export class SubagentModelSelectionCardController { if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving) return this.beginDraft() this.draftEnabled = !this.draftEnabled - this.saved = false this.failed = false if (this.draftEnabled && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() @@ -231,7 +225,6 @@ export class SubagentModelSelectionCardController { const selected = this.beginDraft() if (selected.has(key)) selected.delete(key) else selected.add(key) - this.saved = false this.failed = false this.publish() } @@ -241,7 +234,6 @@ export class SubagentModelSelectionCardController { this.draftEnabled = undefined this.draftSelected = undefined this.draftRevision = undefined - this.saved = false this.failed = false this.publish() } @@ -264,14 +256,12 @@ export class SubagentModelSelectionCardController { || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) || (desiredEnabled && desired.length === 0)) return if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) { - this.saved = false this.failed = true this.publish() return } const generation = this.saveGeneration this.saving = true - this.saved = false this.failed = false this.publish() await this.scope.mutate([ @@ -281,7 +271,6 @@ export class SubagentModelSelectionCardController { if (generation !== this.saveGeneration) return const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false - this.saved = landed this.failed = !landed if (landed) { this.draftEnabled = undefined @@ -296,7 +285,7 @@ export class SubagentModelSelectionCardController { if (this.disposed) return this.catalogGeneration += 1 this.catalogStatus = 'idle' - this.catalogFailures = [] + this.catalogPartial = false if (this.enabled()) void this.loadCatalog() else this.publish() } @@ -313,14 +302,14 @@ export class SubagentModelSelectionCardController { const generation = this.catalogGeneration this.catalogStatus = 'loading' this.catalogGroups = [] - this.catalogFailures = [] + this.catalogPartial = false this.publish() try { const response = await this.api.llm.models({}) if (generation !== this.catalogGeneration) return if (!response.result.ok) throw new Error(response.result.error.message) this.catalogGroups = response.result.value.groups - this.catalogFailures = response.result.value.failures + this.catalogPartial = response.result.value.failures.length > 0 this.catalogStatus = 'ready' } catch { if (generation !== this.catalogGeneration) return @@ -344,8 +333,7 @@ export class SubagentModelSelectionCardController { enabled, candidates: this.candidates(), catalogStatus: this.catalogStatus, - catalogFailures: this.catalogFailures, - saved: this.saved, + catalogPartial: this.catalogPartial, } } diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 46709d057f..7effc16dee 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -88,8 +88,7 @@ function renderSubagentModelSelection(state: Partial { expect(actions.toggleEnabled).toHaveBeenCalledOnce() }) - it('renders adapter candidates and reports a successful save', () => { + it('groups available adapter candidates by provider', () => { const actions = renderSubagentModelSelection({ enabled: true, - saved: true, candidates: [{ key: 'alpha\0fast', provider: 'alpha', @@ -353,7 +351,7 @@ describe('SubagentModelSelectionCard', () => { fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') - expect(screen.getByRole('status').textContent).toBe(en.subagentModelSelectionSaved) + expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') }) @@ -374,7 +372,7 @@ describe('SubagentModelSelectionCard', () => { renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready', - catalogFailures: [{ id: 'beta', name: 'Beta', message: 'offline' }], + catalogPartial: true, candidates: [{ key: 'legacy\0old', provider: 'legacy', @@ -388,6 +386,7 @@ describe('SubagentModelSelectionCard', () => { fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) expect(screen.getByText(en.subagentModelSelectionPartial)).toBeTruthy() expect(screen.getByText(en.subagentModelSelectionUnavailable)).toBeTruthy() + expect(screen.getByText(en.subagentModelSelectionUnavailableGroup)).toBeTruthy() cleanup() renderSubagentModelSelection({ enabled: true, catalogStatus: 'ready' }) diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 4b1a8317e8..3e5002954d 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -480,7 +480,6 @@ describe('SubagentModelSelectionCardController', () => { enabled: true, dirty: false, saving: false, - saved: true, failed: false, }) }) @@ -521,7 +520,6 @@ describe('SubagentModelSelectionCardController', () => { enabled: true, dirty: true, saving: false, - saved: false, }) }) @@ -539,6 +537,7 @@ describe('SubagentModelSelectionCardController', () => { const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().catalogPartial).toBe(true) face.toggleModel('missing') expect(state().dirty).toBe(false) @@ -576,7 +575,7 @@ describe('SubagentModelSelectionCardController', () => { ], 5) }) expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - enabled: false, dirty: false, saved: true, + enabled: false, dirty: false, }) }) From 5a5e1b73733546a5880e1564bce60a43ac0e06b6 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 11:58:23 +0800 Subject: [PATCH 040/130] test(ui-settings-plugins): cover provider model grouping --- .../tests/section.client.spec.tsx | 31 +++++++++++++------ 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index 7effc16dee..d5207b5fe7 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -337,15 +337,26 @@ describe('SubagentModelSelectionCard', () => { it('groups available adapter candidates by provider', () => { const actions = renderSubagentModelSelection({ enabled: true, - candidates: [{ - key: 'alpha\0fast', - provider: 'alpha', - model: 'fast', - providerName: 'Alpha API', - modelName: 'Fast', - available: true, - selected: true, - }], + candidates: [ + { + key: 'alpha\0fast', + provider: 'alpha', + model: 'fast', + providerName: 'Alpha API', + modelName: 'Fast', + available: true, + selected: true, + }, + { + key: 'alpha\0deep', + provider: 'alpha', + model: 'deep', + providerName: 'Alpha API', + modelName: 'Deep', + available: true, + selected: false, + }, + ], catalogStatus: 'ready', }) fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) @@ -353,7 +364,9 @@ describe('SubagentModelSelectionCard', () => { expect(screen.getByRole('switch').getAttribute('aria-checked')).toBe('true') expect(screen.getByText('Alpha API', { exact: true })).toBeTruthy() fireEvent.click(screen.getByRole('checkbox', { name: /Fast/ })) + fireEvent.click(screen.getByRole('checkbox', { name: /Deep/ })) expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0fast') + expect(actions.toggleModel).toHaveBeenCalledWith('alpha\0deep') }) it('renders directory progress, failures, unavailable routes, and validation', () => { From a5cc8a2186fb9ab76f2538430e596102a586e770 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 13:06:30 +0800 Subject: [PATCH 041/130] fix(notices): resolve current installed dependency versions --- THIRD_PARTY_NOTICES.md | 2 +- scripts/gen-third-party-notices.spec.ts | 18 ++++++++ scripts/gen-third-party-notices.ts | 60 +++++++++++++++++++------ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 08c7f13f0f..616dcd1909 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -165,7 +165,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`cytoscape`](https://github.com/cytoscape/cytoscape.js) | MIT | | [`cytoscape-cose-bilkent`](https://github.com/cytoscape/cytoscape.js-cose-bilkent) | MIT | | [`dayjs`](https://github.com/iamkun/dayjs) | MIT | -| [`debug`](https://github.com/visionmedia/debug) | MIT | +| [`debug`](https://github.com/debug-js/debug) | MIT | | [`esbuild`](https://github.com/evanw/esbuild) | MIT | | [`eslint-plugin-sonarjs`](https://github.com/SonarSource/SonarJS) | LGPL-3.0-only | | [`execa`](https://github.com/sindresorhus/execa) | MIT | diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index 68812143c3..33d8661aed 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -114,6 +114,24 @@ describe('virtualManifest', () => { } }) + it('selects the requested version when the store retains historical copies', () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-notices-version-')) + try { + const name = '@scope/pkg' + const store = join(root, 'store') + for (const version of ['1.0.0', '2.0.0']) { + const manifestDir = join(store, `${name.replace('/', '+')}@${version}`, 'node_modules', name) + mkdirSync(manifestDir, { recursive: true }) + writeFileSync(join(manifestDir, 'package.json'), JSON.stringify({ name, version, license: 'MIT' })) + } + + expect(virtualManifest(store, name, '2.0.0')).toMatchObject({ name, version: '2.0.0' }) + expect(virtualManifest(store, name, '3.0.0')).toBeUndefined() + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + it('returns undefined when neither the prefix nor the content scan finds the package', () => { const root = mkdtempSync(join(tmpdir(), 'dsh-notices-miss-')) try { diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index 4f56031c1e..e076677055 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -9,7 +9,7 @@ */ import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { dirname, resolve } from 'node:path' import * as yaml from 'js-yaml' import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml' import parseSpdx from 'spdx-expression-parse' @@ -248,38 +248,72 @@ export function claudeDistributionFromManifest( * * @param virtual - the `.pnpm` virtual store directory to scan. * @param name - the external package name, exactly as `node_modules` spells it. + * @param expectedVersion - exact version required when the store retains more than one. * @returns the parsed manifest, or `undefined` when neither the prefix match - * nor the content scan finds the package's `package.json`. + * nor the content scan finds the requested package version. */ -export function virtualManifest(virtual: string, name: string): VirtualManifest | undefined { +export function virtualManifest( + virtual: string, + name: string, + expectedVersion?: string, +): VirtualManifest | undefined { const prefix = `${name.replace('/', '+')}@` - const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix)) - if (entry !== undefined) { - return JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest + const entries = readdirSync(virtual) + for (const entry of entries.filter(dir => dir.startsWith(prefix))) { + const manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as VirtualManifest + if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest } - for (const dir of readdirSync(virtual)) { + for (const dir of entries) { const candidate = resolve(virtual, dir, 'node_modules', name, 'package.json') if (existsSync(candidate)) { - return JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + const manifest = JSON.parse(readFileSync(candidate, 'utf8')) as VirtualManifest + if (expectedVersion === undefined || manifest.version === expectedVersion) return manifest } } return undefined } +const workspaceLinkedManifestCache = new Map() + +/** + * Resolve the package version selected for a declaring workspace instead of an + * unrelated historical version that still occupies the shared virtual store. + * @param name - external package identity. + * @returns the first current workspace link for that package, when installed. + */ +function workspaceLinkedManifest(name: string): VirtualManifest | undefined { + if (workspaceLinkedManifestCache.has(name)) return workspaceLinkedManifestCache.get(name) + for (const [path, manifest] of loadWorkspaceManifests().manifests) { + if (!ALL_KINDS.some(kind => name in (manifest[kind] ?? {}))) continue + const linked = resolve(root, dirname(path), 'node_modules', name, 'package.json') + if (!existsSync(linked)) continue + const found = JSON.parse(readFileSync(linked, 'utf8')) as VirtualManifest + workspaceLinkedManifestCache.set(name, found) + return found + } + workspaceLinkedManifestCache.set(name, undefined) + return undefined +} + /** Resolve one installed external package manifest from either pnpm store. */ -function installedManifest(name: string): VirtualManifest | undefined { +function installedManifest(name: string, expectedVersion?: string): VirtualManifest | undefined { + const linked = workspaceLinkedManifest(name) + if (linked !== undefined && (expectedVersion === undefined || linked.version === expectedVersion)) return linked let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined // Workspace-local link farms can expose a dependency that is not linked at // the repository root; both are backed by the root workspace's lockfile. for (const store of ['node_modules', 'native/landlock-run/node_modules']) { const direct = resolve(root, store, name, 'package.json') if (existsSync(direct)) { - manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest - break + const candidate = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest + if (expectedVersion === undefined || candidate?.version === expectedVersion) { + manifest = candidate + break + } } const virtual = resolve(root, store, '.pnpm') if (!existsSync(virtual)) continue - manifest = virtualManifest(virtual, name) + manifest = virtualManifest(virtual, name, expectedVersion) if (manifest !== undefined) break } return manifest @@ -308,7 +342,7 @@ function collectClaudeDistribution(): ClaudeDistribution { const distribution = claudeDistributionFromManifest(manifest) let installedPayloads = 0 for (const payload of distribution.payloads) { - const installed = installedManifest(payload.name) + const installed = installedManifest(payload.name, payload.version) if (installed === undefined) continue installedPayloads += 1 if ( From bf7020ade24b7490ef4801cac59f6160f353aab5 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 13:06:38 +0800 Subject: [PATCH 042/130] test(subagent): migrate model selection fixtures --- .../tests/fixtures/loader/cordis.yml | 11 +++- .../fixtures/loader/scoped-tool-subagent.ts | 19 ++++++ .../tool-schemas.expected.json | 31 +--------- .../subagent-dsh-sdk-dynamic-route/cordis.yml | 11 +++- .../notifications.expected.jsonl | 54 ++++++++-------- .../session.jsonl | 9 +-- .../tool-schemas.1.expected.json | 31 +--------- .../tool-schemas.expected.json | 62 +------------------ .../tool-schemas.expected.json | 62 +------------------ .../tool-schemas.expected.json | 31 +--------- snapshots/session/text-turn/cordis.yml | 1 - 11 files changed, 76 insertions(+), 246 deletions(-) create mode 100644 packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml index a72b5fd79e..f0234ffdf7 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/cordis.yml @@ -25,12 +25,19 @@ DSH_TELEMETRY_DISABLED: '1' DSH_TEST_CHILD_FAILURE: !!js String(process.env.DSH_TEST_CHILD_FAILURE ?? '') +- id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + config: + enabled: true + allowedModels: + - provider: mock + model: mock-routed + - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' + name: './scoped-tool-subagent.ts' config: provider: dsh-sdk toolName: subagent - enableModelSelection: true agentOptions: maxTokens: 777 # The SDK backend advertises no depthLimit: the child harness owns its own diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts new file mode 100644 index 0000000000..5083694807 --- /dev/null +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -0,0 +1,19 @@ +/** Mount the SDK delegation tool in each fixture Agent's scope. */ + +import type { Context } from '@deepseek-ai/cordis' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import type { Config } from '@deepseek-ai/dsh-tool-subagent' + +export const name = 'scoped-tool-subagent' +export const inject = ['agents', 'subagentModelSelection'] + +/** + * Install the configured delegation tool before a published Agent starts its loop. + * @param ctx - fixture Host context carrying Agent lifecycle events. + * @param config - delegation-tool configuration forwarded into each Agent scope. + */ +export function apply(ctx: Context, config: Config): void { + ctx.on('agent/created', ({ agent }) => { + agent.ctx.plugin(ToolSubagent, { ...config, modelSelectionSettings: true }) + }) +} diff --git a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json index 2e0fc2b44b..b3a1813e8b 100644 --- a/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-diagnostic/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml index 100c7c20aa..4b6e415081 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/cordis.yml @@ -35,12 +35,19 @@ env: DSH_TELEMETRY_DISABLED: '1' + - id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + config: + enabled: true + allowedModels: + - provider: mock + model: mock-routed + - id: tool-subagent-dsh-sdk - name: '@deepseek-ai/dsh-tool-subagent' + name: '../../../packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts' config: provider: dsh-sdk toolName: subagent - enableModelSelection: true enableRunInBackground: false agentOptions: maxTokens: 777 diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl index 9dee99a052..315ff76482 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/notifications.expected.jsonl @@ -1,29 +1,29 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":9,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[7],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"mock-delegate"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":18,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":19,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":20,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":21,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":27,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":28,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":29,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":5,"time":0,"data":{"turn":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":7,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":10,"time":0,"data":{"title":"Delegate once using the requested","messageSeqs":[8],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":11,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":12,"time":0,"data":{"provider":"deepseek-official","model":"mock-delegate"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call-delegate","name":"subagent","argumentsDelta":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl index cc800a84cb..9402e19672 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/session.jsonl @@ -2,13 +2,14 @@ {"type":"permission/preset","data":{"preset":"workspace-write"}} {"type":"sandbox/mode","data":{"mode":"workspace-write"}} {"type":"approval/policy","data":{"policy":"ask"}} +{"type":"subagent/model-selection-policy","data":{"allowedModels":[{"provider":"mock","model":"mock-routed"}]}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} {"type":"turn/start","data":{"turn":1}} {"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","data":{"turn":1,"step":1}} {"type":"user/message","data":{"content":[{"type":"text","text":"Delegate once using the requested child route."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"} -{"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","data":{"title":"Delegate once using the requested","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"mock-delegate"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"mock-delegate"}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -16,9 +17,9 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:3}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call-delegate","name":"subagent","arguments":"{\"description\":\"route probe\",\"prompt\":\"report your route and workspace\",\"provider\":\"mock\",\"model\":\"mock-routed\",\"reasoning_effort\":\"max\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call-delegate"},"content":[{"type":"tool-result","toolCallId":"call-delegate","content":[{"type":"text","text":"child route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -26,6 +27,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"child reported:\nchild route: mock/mock-routed/max/777; cwd: {{cwd}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"mock-delegate"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json index ba1d2e415d..fe0882fe53 100644 --- a/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json +++ b/snapshots/sdk/subagent-dsh-sdk-dynamic-route/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/agent-instructions/tool-schemas.expected.json b/snapshots/session/agent-instructions/tool-schemas.expected.json index 0d475b2d80..36df7a0481 100644 --- a/snapshots/session/agent-instructions/tool-schemas.expected.json +++ b/snapshots/session/agent-instructions/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." @@ -990,23 +961,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -1191,7 +1145,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -1203,18 +1157,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/compaction-recovery/tool-schemas.expected.json b/snapshots/session/compaction-recovery/tool-schemas.expected.json index 0d475b2d80..36df7a0481 100644 --- a/snapshots/session/compaction-recovery/tool-schemas.expected.json +++ b/snapshots/session/compaction-recovery/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." @@ -990,23 +961,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -1191,7 +1145,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -1203,18 +1157,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json index 2f46955ebd..796bac1779 100644 --- a/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json +++ b/snapshots/session/subagent-acp-diagnostic/tool-schemas.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -461,7 +444,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -473,18 +456,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/text-turn/cordis.yml b/snapshots/session/text-turn/cordis.yml index 2c8ffef06f..ea97cc0616 100644 --- a/snapshots/session/text-turn/cordis.yml +++ b/snapshots/session/text-turn/cordis.yml @@ -51,7 +51,6 @@ config: provider: spawn toolName: subagent - enableModelSelection: true backgroundMode: continuable maxDepth: 1 From 1c0e46870ce28b3389f62d1ee55c731a60f795ae Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 13:51:45 +0800 Subject: [PATCH 043/130] test(subagent): keep scoped fixture config explicit --- knip.json | 3 +-- .../tests/fixtures/loader/scoped-tool-subagent.ts | 12 +++++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/knip.json b/knip.json index b08fe22a25..6fa4cac826 100644 --- a/knip.json +++ b/knip.json @@ -696,8 +696,7 @@ "@deepseek-ai/dsh-llm-deepseek", "@deepseek-ai/dsh-session-checkpoint-policy", "@deepseek-ai/dsh-session-persistence-jsonl", - "@deepseek-ai/dsh-skill-filesystem", - "@deepseek-ai/dsh-tool-subagent" + "@deepseek-ai/dsh-skill-filesystem" ] }, "packages/shell/tool-pwsh": { diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index 5083694807..d333821d5c 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -14,6 +14,16 @@ export const inject = ['agents', 'subagentModelSelection'] */ export function apply(ctx: Context, config: Config): void { ctx.on('agent/created', ({ agent }) => { - agent.ctx.plugin(ToolSubagent, { ...config, modelSelectionSettings: true }) + agent.ctx.plugin(ToolSubagent, { + provider: config.provider, + toolName: config.toolName, + modelSelectionSettings: true, + enableRunInBackground: config.enableRunInBackground, + backgroundMode: config.backgroundMode, + agentOptions: config.agentOptions, + persona: config.persona, + toolFilter: config.toolFilter, + maxDepth: config.maxDepth, + }) }) } From a7614f971e6e33abdcbd3bb348c66c20dc59d49a Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 15:00:27 +0800 Subject: [PATCH 044/130] fix(subagent): omit undefined scoped fixture options --- .../fixtures/loader/scoped-tool-subagent.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index d333821d5c..79e53d1a35 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -16,14 +16,16 @@ export function apply(ctx: Context, config: Config): void { ctx.on('agent/created', ({ agent }) => { agent.ctx.plugin(ToolSubagent, { provider: config.provider, - toolName: config.toolName, modelSelectionSettings: true, - enableRunInBackground: config.enableRunInBackground, - backgroundMode: config.backgroundMode, - agentOptions: config.agentOptions, - persona: config.persona, - toolFilter: config.toolFilter, - maxDepth: config.maxDepth, + ...(config.toolName === undefined ? {} : { toolName: config.toolName }), + ...(config.enableRunInBackground === undefined + ? {} + : { enableRunInBackground: config.enableRunInBackground }), + ...(config.backgroundMode === undefined ? {} : { backgroundMode: config.backgroundMode }), + ...(config.agentOptions === undefined ? {} : { agentOptions: config.agentOptions }), + ...(config.persona === undefined ? {} : { persona: config.persona }), + ...(config.toolFilter === undefined ? {} : { toolFilter: config.toolFilter }), + ...(config.maxDepth === undefined ? {} : { maxDepth: config.maxDepth }), }) }) } From 4cc1f5e0ffb8b68de7ca53ac0839f179de5e6cf9 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 15:00:34 +0800 Subject: [PATCH 045/130] fix(ui-settings-plugins): relax Subagent card layout --- .../src/client/PluginCard.tsx | 14 +++++++- .../SubagentModelSelectionCard.module.css | 14 +++++++- .../src/client/SubagentModelSelectionCard.tsx | 34 +++++++++++-------- .../tests/section.client.spec.tsx | 34 +++++++++++++++++-- 4 files changed, 76 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx index 6965939561..472a9f0e85 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/PluginCard.tsx @@ -14,7 +14,7 @@ * disabled card the user cannot act on. */ -import { useState, type ReactNode } from 'react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import clsx from 'clsx' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { CardShell } from './card-form.ts' @@ -46,7 +46,19 @@ export interface PluginCardProps { */ export function PluginCard(props: PluginCardProps) { const [open, setOpen] = useState(false) + const saveStarted = useRef(false) const { state } = props + // Collapse only after Host-confirmed settlement; a rejected write keeps its + // diagnostics and retained drafts visible for correction. + useEffect(() => { + if (state.saving) { + saveStarted.current = true + return + } + if (!saveStarted.current) return + saveStarted.current = false + if (!state.dirty && !state.failed) setOpen(false) + }, [state.dirty, state.failed, state.saving]) if (!state.available) return null const title = props.t(props.titleKey) const blocked = !state.dirty || state.invalid || state.saving diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index c71952a0bd..00105fbf24 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -1,12 +1,24 @@ +.permission { + display: grid; + gap: 6px; + padding: 12px 0; +} + .toggleRow { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 16px; font-size: 13px; + line-height: 1.5; color: var(--dsw-alias-label-secondary); } +.toggleLabel { + flex: 1; + min-width: 0; +} + .switch { box-sizing: border-box; position: relative; diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index 962dfdb41c..e82721f908 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -70,24 +70,28 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp onSave={props.save} onDiscard={props.discard} > -
    - {t('subagentModelSelectionToggle')} - +
    +
    + {t('subagentModelSelectionToggle')} + +
    +

    + {t(state.enabled ? 'subagentModelSelectionChoose' : 'subagentModelSelectionOff')} +

    {state.enabled ? (
    -

    {t('subagentModelSelectionChoose')}

    {state.catalogStatus === 'loading' ?

    {t('subagentModelSelectionLoading')}

    : null} @@ -130,7 +134,7 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp {state.invalid ?

    {t('subagentModelSelectionRequired')}

    : null}
    ) - :

    {t('subagentModelSelectionOff')}

    } + : null} ) } diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index d5207b5fe7..cf37eb05b5 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -69,7 +69,7 @@ function renderConfigurable(namespaces: string[], cards: Record render() } -function renderBash(state: Partial = {}) { +function renderBashCard(state: Partial = {}) { const store = createSnapshotStore({ ...settled, timeoutMs: field('60000'), @@ -79,7 +79,11 @@ function renderBash(state: Partial = {}) { const actions = cardActions() const props = { ...actions, t, useBashCard: bindSnapshotSelector(store) } as unknown as BashCardProps render() - return actions + return { actions, store } +} + +function renderBash(state: Partial = {}) { + return renderBashCard(state).actions } function renderSubagentModelSelection(state: Partial = {}) { @@ -320,6 +324,30 @@ describe('BashCard', () => { expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() }) + + it('collapses after a successful save settles', () => { + const { actions, store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + expect(actions.save).toHaveBeenCalledOnce() + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), dirty: false, saving: false }) }) + + expect(screen.queryByLabelText(en.bashTimeoutMs)).toBeNull() + }) + + it('keeps a failed save open', () => { + const { store } = renderBashCard({ dirty: true }) + fireEvent.click(screen.getByText(en.bashTitle)) + fireEvent.click(screen.getByRole('button', { name: en.save })) + + act(() => { store.set({ ...store.getSnapshot(), saving: true }) }) + act(() => { store.set({ ...store.getSnapshot(), failed: true, saving: false }) }) + + expect(screen.getByLabelText(en.bashTimeoutMs)).toBeTruthy() + expect(screen.getByText(en.saveFailed)).toBeTruthy() + }) }) describe('SubagentModelSelectionCard', () => { From d5787b18475ed53493c189edbee2184f4f6a397f Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 15:09:27 +0800 Subject: [PATCH 046/130] test(web): expect plugin cards to collapse after save --- apps/web/tests/plugin-config.e2e.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index e3b34776bb..ec0d42c40d 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -103,24 +103,29 @@ describe('web e2e: plugin configuration section', () => { await firstModel.check() await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') + const expandSubagent = dialog.getByRole('button', { name: '展开设置: Subagent' }) + await expandSubagent.waitFor({ timeout: 5_000 }) await expect.poll(async () => (await settingsDocument()).includes('subagent-model-selection:'), { timeout: 10_000 }) .toBe(true) expect(await settingsDocument()).toContain('enabled: true') expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('true') await expect.poll(() => dialog.getByRole('button', { name: '保存', exact: true }).isDisabled()).toBe(true) expect(await dialog.getByText('未保存', { exact: true }).count()).toBe(0) await toggle.click() await dialog.getByRole('button', { name: '保存', exact: true }).click() - await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') + await expandSubagent.waitFor({ timeout: 5_000 }) await expect.poll(async () => (await settingsDocument()).includes('enabled: false'), { timeout: 10_000 }) .toBe(true) expect(await settingsDocument()).toContain('allowedModels:') expect(await settingsDocument()).toContain('provider:') expect(await settingsDocument()).toContain('model:') + await expandSubagent.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: 5_000 }).toBe('false') expect(tripwire.pageErrors).toEqual([]) }, 60_000) @@ -145,6 +150,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs: 12000'), { timeout: 10_000 }) .toBe(true) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() // Presence in the user layer is what the badge reports, and the reset is // offered only for a field that has one. await expect.poll(() => dialog.getByText('已覆盖').count(), { timeout: 5_000 }).toBe(1) @@ -203,6 +211,9 @@ describe('web e2e: plugin configuration section', () => { await expect.poll(async () => (await settingsDocument()).includes('timeoutMs'), { timeout: 10_000 }) .toBe(false) + const expandTerminal = dialog.getByRole('button', { name: '展开设置: 终端' }) + await expandTerminal.waitFor({ timeout: 5_000 }) + await expandTerminal.click() expect(await timeout.inputValue()).toBe('60000') expect(await dialog.getByText('已覆盖').count()).toBe(0) expect(tripwire.pageErrors).toEqual([]) From cbacceca4b409a1ae9bb6ff63e317c51ecf8b6cc Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 16:04:56 +0800 Subject: [PATCH 047/130] fix(ui-settings-plugins): place Subagent after Agent loop --- .../tests/expected/plugin-config/section.expected.md | 8 ++++---- apps/web/tests/plugin-config.e2e.ts | 4 ++-- .../client/ui-settings-plugins/src/client/index.ts | 12 ++++++------ .../ui-settings-plugins/tests/apply.client.spec.ts | 2 +- .../cordis-client-runner/src/client/slot-catalog.ts | 2 +- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/expected/plugin-config/section.expected.md b/apps/web/tests/expected/plugin-config/section.expected.md index 1a95e7032d..08e89d18f8 100644 --- a/apps/web/tests/expected/plugin-config/section.expected.md +++ b/apps/web/tests/expected/plugin-config/section.expected.md @@ -24,10 +24,6 @@ - tab "插件列表" - tabpanel "插件配置": - list: - - listitem: - - 'button "展开设置: Subagent"': - - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 - - img - listitem: - 'button "展开设置: 终端"': - text: 终端 限制 agent 运行的每一条命令。 @@ -36,6 +32,10 @@ - 'button "展开设置: Agent 循环"': - text: Agent 循环 Agent 如何派发工具调用。 - img + - listitem: + - 'button "展开设置: Subagent"': + - text: Subagent 控制 Agent 为 Subagent 选择模型的权限。 + - img - listitem: - 'button "展开设置: 网页搜索"': - text: 网页搜索 DeepSeek 搜索提供方。 diff --git a/apps/web/tests/plugin-config.e2e.ts b/apps/web/tests/plugin-config.e2e.ts index ec0d42c40d..9d7982d7d8 100644 --- a/apps/web/tests/plugin-config.e2e.ts +++ b/apps/web/tests/plugin-config.e2e.ts @@ -75,8 +75,8 @@ describe('web e2e: plugin configuration section', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-config-cards')) const dialog = await openPlugins() - // Every card the shipped web composition exposes: subagent selection, the - // shell executor, the agent loop, and the DeepSeek search provider. + // Every card the shipped web composition exposes: the shell executor, the + // agent loop, subagent selection, and the DeepSeek search provider. await dialog.getByText('Subagent', { exact: true }).waitFor({ timeout: 10_000 }) expect(await dialog.getByRole('button', { name: '展开设置: Subagent' }).count()).toBe(1) await dialog.getByText('终端', { exact: true }).waitFor({ timeout: 10_000 }) diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 4b25ae2871..0c8761882c 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -165,12 +165,6 @@ export function apply(ctx: ClientContext): void { }, ConfigurablePluginsTab)) ctx.slots.inject('settings.plugin.item', function* () { - yield ctx.slots.register({ - name: 'settings.plugin.item', - key: SUBAGENT_MODEL_SELECTION_NS, - locale: NS, - inject: () => subagentModelSelection.inject(), - }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: SHELL_NS, @@ -183,6 +177,12 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => agentLoop.inject(), }, AgentLoopCard) + yield ctx.slots.register({ + name: 'settings.plugin.item', + key: SUBAGENT_MODEL_SELECTION_NS, + locale: NS, + inject: () => subagentModelSelection.inject(), + }, SubagentModelSelectionCard) yield ctx.slots.register({ name: 'settings.plugin.item', key: WEB_SEARCH_NS, diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index cf237667fc..9f0f00e93b 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -126,7 +126,7 @@ describe('ui-settings-plugins apply', () => { await ctx.plugin({ inject: [...inject], apply }).await() expect(slots.entries('settings.plugin.item').map(entry => entry.options.key)) - .toEqual(['subagent-model-selection', 'shell', 'agent-loop', 'web-search-deepseek']) + .toEqual(['shell', 'agent-loop', 'subagent-model-selection', 'web-search-deepseek']) }) it('dispatches the served namespaces its cards claim, and no others', async () => { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4540e2b743..35caa57d51 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1715,9 +1715,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ slotInject: '', declaredBy: 'an entry in \'settings.plugins.tab\' (client-ui-settings-plugins), so it exists while that entry is mounted', occupants: [ - 'client-ui-settings-plugins SubagentModelSelectionCard', 'client-ui-settings-plugins BashCard', 'client-ui-settings-plugins AgentLoopCard', + 'client-ui-settings-plugins SubagentModelSelectionCard', 'client-ui-settings-plugins WebSearchCard', ], replaceRisk: 'none', From aad90d5cf3f6a3b98626b0869a070557d3448dab Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Wed, 26 Aug 2026 16:40:58 +0800 Subject: [PATCH 048/130] fix(ui-settings-plugins): preserve model selection drafts --- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 4 +- ...ser-authorized-subagent-model-routes.zh.md | 4 +- .../SubagentModelSelectionCard.module.css | 6 +- .../src/client/SubagentModelSelectionCard.tsx | 3 + .../ui-settings-plugins/src/client/index.ts | 2 +- .../ui-settings-plugins/src/client/locales.ts | 4 +- ...ubagent-model-selection-card-controller.ts | 73 +++++++----- .../tests/apply.client.spec.ts | 2 +- .../tests/section.client.spec.tsx | 9 ++ .../tests/stores.client.spec.ts | 111 +++++++++++++++++- .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 4 +- packages/subagent/tool-subagent/README.zh.md | 4 +- 14 files changed, 184 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 803fcddb1f..4ba77e05b1 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: 3bb76eb8941dd5a7f86e95cfafe14516a4f948d7 -2026-08-24-user-authorized-subagent-model-routes.zh.md: 5defbd0ee0921666a119eaac914580dad747868c +2026-08-24-user-authorized-subagent-model-routes.md: 0f9816ae89562545c267d87713c13faecd9efc66 +2026-08-24-user-authorized-subagent-model-routes.zh.md: ca63215a5ec7ae56ae2f077a7fb19fc1e204527c diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index 3bb76eb894..0f9816ae89 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -10,7 +10,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an ## Decision -The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase stored authorization. +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. @@ -36,7 +36,7 @@ Model selection has no unrestricted static mode. The default-off Host setting is - Adapter removals or catalog failures can reduce what discovery currently lists without deleting the saved route decision; an exact authorized route remains usable when its adapter accepts it even if the advisory catalog omits it. - The allowlist itself consumes no parent-request tokens. Only a `list_subagent_models` result enters the transcript. - The policy event is log-only and is appended while an Agent is composed, before either SDK begins its run subscription. Shipped SDK profiles do not enable this Web-owned preference, so the event changes neither SDK's expected notifications or persisted-session output; package restore tests own its durable projection instead of fabricating an SDK composition solely to emit it. -- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. +- Unit coverage pins settings validation, malformed durable values, Session sampling and inheritance, discovery intersection, executor denial, live UI catalog invalidation, staged-route retention, connection-generation invalidation, staged whole-array writes, stale-revision rejection, and retry after scoped installation failure. The assembled Web scenario pins the real settings document and Plugins card flow. ## Related decisions diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index 5defbd0ee0..ca63215a5e 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权。 +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 @@ -36,7 +36,7 @@ Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` - 适配器移除或目录失败可以减少发现当前列出的内容,但不会删除已存路由决定;即使建议性目录省略某条精确已授权路由,只要适配器接受它,该路由仍然可用。 - 允许列表本身不消耗父级请求 token。只有 `list_subagent_models` 结果进入 transcript。 - 策略事件仅存在于日志,并在 Agent 组合期间、两套 SDK 开始订阅运行前追加。随附 SDK profile 不启用这项 Web 自有偏好,因此该事件不会改变任一 SDK 的预期通知或持久 Session 输出;其持久投影由包级恢复测试负责,不会为了发出该事件而虚构 SDK 组合。 -- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存后的整数组写入、过期 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 +- 单元覆盖固定设置校验、异常持久值、Session 取样与继承、发现交集、执行器拒绝、UI 实时目录失效、暂存路由保留、连接换代失效、暂存后的整数组写入、陈旧 revision 拒绝,以及作用域安装失败后的重试。组装 Web 场景固定真实设置文档与 Plugins 设置卡流程。 ## Related decisions diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css index 00105fbf24..3508ec8e48 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.module.css @@ -66,7 +66,8 @@ .hint, .notice, -.invalid { +.invalid, +.conflict { margin: 0; font-size: 12px; line-height: 1.5; @@ -77,7 +78,8 @@ color: var(--dsw-alias-label-tertiary); } -.invalid { +.invalid, +.conflict { color: var(--dsw-alias-label-error); } diff --git a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx index e82721f908..e5ba1ffc11 100644 --- a/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx +++ b/packages/client/ui-settings-plugins/src/client/SubagentModelSelectionCard.tsx @@ -135,6 +135,9 @@ export function SubagentModelSelectionCard(props: SubagentModelSelectionCardProp
    ) : null} + {state.conflicted + ?

    {t('subagentModelSelectionConflict')}

    + : null} ) } diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 0c8761882c..8dd09f1d03 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -90,7 +90,7 @@ export function apply(ctx: ClientContext): void { 'ui-settings-plugins: subagent settings invalidations', ) ctx.effect( - () => ctx.on('connection/reset', () => { subagentModelSelection.resetCatalog() }), + () => ctx.on('connection/reset', () => { subagentModelSelection.resetConnection() }), 'ui-settings-plugins: subagent connection generation', ) ctx.effect(() => () => { subagentModelSelection.dispose() }, 'ui-settings-plugins: subagent preference') diff --git a/packages/client/ui-settings-plugins/src/client/locales.ts b/packages/client/ui-settings-plugins/src/client/locales.ts index 018d87d47d..b68ab80beb 100644 --- a/packages/client/ui-settings-plugins/src/client/locales.ts +++ b/packages/client/ui-settings-plugins/src/client/locales.ts @@ -16,7 +16,7 @@ export type PluginsSettingsLocaleKey = | 'subagentModelSelectionLoading' | 'subagentModelSelectionLoadFailed' | 'subagentModelSelectionRetry' | 'subagentModelSelectionPartial' | 'subagentModelSelectionUnavailable' | 'subagentModelSelectionUnavailableGroup' | 'subagentModelSelectionEmpty' - | 'subagentModelSelectionRequired' | 'subagentModelSelectionOff' + | 'subagentModelSelectionRequired' | 'subagentModelSelectionConflict' | 'subagentModelSelectionOff' /** English copy. */ export const en: Record = { @@ -70,6 +70,7 @@ export const en: Record = { subagentModelSelectionUnavailableGroup: 'Saved but currently unavailable', subagentModelSelectionEmpty: 'No model provider currently advertises a model.', subagentModelSelectionRequired: 'Select at least one model before saving.', + subagentModelSelectionConflict: 'Settings changed elsewhere. Discard your draft and try again.', subagentModelSelectionOff: 'Subagents use configured defaults or inherit the parent agent\'s model. Saved model choices are retained.', } @@ -125,5 +126,6 @@ export const zh: Record = { subagentModelSelectionUnavailableGroup: '已保存但当前不可用', subagentModelSelectionEmpty: '当前没有模型提供方公布模型。', subagentModelSelectionRequired: '保存前请至少选择一个模型。', + subagentModelSelectionConflict: '设置已在其他位置更新。请放弃修改后重试。', subagentModelSelectionOff: '关闭后,Subagent 使用配置的默认模型或继承父 Agent 的模型;已选模型会保留。', } diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 8af7361f13..74917edc56 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -49,6 +49,8 @@ export interface SubagentModelSelectionCardState extends CardShell { catalogStatus: 'idle' | 'loading' | 'ready' | 'error' /** Whether any provider-local catalog request failed. */ catalogPartial: boolean + /** Whether a newer Host revision invalidated the current draft. */ + conflicted: boolean } /** Registration-side face for the subagent model-selection card. */ @@ -130,10 +132,11 @@ export class SubagentModelSelectionCardController { private catalogPartial = false private catalogStatus: SubagentModelSelectionCardState['catalogStatus'] = 'idle' private draftEnabled: boolean | undefined - private draftSelected: Set | undefined + private draftRoutes: Map | undefined private draftRevision: number | undefined private saving = false private failed = false + private conflicted = false private disposed = false private saveGeneration = 0 private catalogGeneration = 0 @@ -150,9 +153,11 @@ export class SubagentModelSelectionCardController { ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { - if (!this.saving && this.draftSelected !== undefined + if (!this.saving && this.draftRoutes !== undefined && this.scope.getSnapshot().revision !== this.draftRevision) { - this.failed = true + if (this.currentEnabled() === this.enabled() + && sameRoutes(this.currentRoutes(), this.desiredRoutes())) this.clearDraft() + else this.conflicted = true } if (this.enabled() && this.catalogStatus === 'idle') void this.loadCatalog() this.publish() @@ -192,21 +197,23 @@ export class SubagentModelSelectionCardController { } private selected(): Set { - return this.draftSelected ?? new Set(this.currentRoutes().map(subagentModelKey)) + return new Set(this.draftRoutes?.keys() ?? this.currentRoutes().map(subagentModelKey)) } private enabled(): boolean { return this.draftEnabled ?? this.currentEnabled() } - private beginDraft(): Set { - if (this.draftSelected === undefined) { + private beginDraft(): Map { + if (this.draftRoutes === undefined) { const snapshot = this.scope.getSnapshot() this.draftEnabled = snapshot.value?.enabled ?? false - this.draftSelected = new Set(snapshot.value?.allowedModels.map(subagentModelKey) ?? []) + this.draftRoutes = new Map( + snapshot.value?.allowedModels.map(route => [subagentModelKey(route), { ...route }]) ?? [], + ) this.draftRevision = snapshot.revision } - return this.draftSelected + return this.draftRoutes } private toggleEnabled(): void { @@ -221,31 +228,37 @@ export class SubagentModelSelectionCardController { private toggleModel(key: string): void { if (!this.enabled() || this.saving || !this.scope.getSnapshot().writable) return - if (!this.candidates().some(candidate => candidate.key === key)) return - const selected = this.beginDraft() - if (selected.has(key)) selected.delete(key) - else selected.add(key) + const candidate = this.candidates().find(candidate => candidate.key === key) + if (candidate === undefined) return + const routes = this.beginDraft() + if (routes.has(key)) routes.delete(key) + else routes.set(key, { provider: candidate.provider, model: candidate.model }) this.failed = false this.publish() } + private clearDraft(): void { + this.draftEnabled = undefined + this.draftRoutes = undefined + this.draftRevision = undefined + this.failed = false + this.conflicted = false + } + private discard(): void { if (this.saving) return - this.draftEnabled = undefined - this.draftSelected = undefined - this.draftRevision = undefined - this.failed = false + this.clearDraft() this.publish() } private candidates(): SubagentModelCandidate[] { - return subagentModelCandidates(this.catalogGroups, this.currentRoutes(), this.selected()) + const retained = new Map(this.currentRoutes().map(route => [subagentModelKey(route), route])) + for (const [key, route] of this.draftRoutes ?? []) retained.set(key, route) + return subagentModelCandidates(this.catalogGroups, [...retained.values()], this.selected()) } private desiredRoutes(): AllowedSubagentModel[] { - return this.candidates() - .filter(candidate => candidate.selected) - .map(({ provider, model }) => ({ provider, model })) + return [...this.draftRoutes?.values() ?? this.currentRoutes()].map(route => ({ ...route })) } private async save(): Promise { @@ -255,14 +268,15 @@ export class SubagentModelSelectionCardController { if (this.disposed || snapshot.status !== 'ready' || !snapshot.writable || this.saving || (this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired)) || (desiredEnabled && desired.length === 0)) return - if (this.draftSelected !== undefined && snapshot.revision !== this.draftRevision) { - this.failed = true + if (this.draftRoutes !== undefined && snapshot.revision !== this.draftRevision) { + this.conflicted = true this.publish() return } const generation = this.saveGeneration this.saving = true this.failed = false + this.conflicted = false this.publish() await this.scope.mutate([ { op: 'set', path: ['enabled'], value: desiredEnabled }, @@ -272,11 +286,7 @@ export class SubagentModelSelectionCardController { const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) this.saving = false this.failed = !landed - if (landed) { - this.draftEnabled = undefined - this.draftSelected = undefined - this.draftRevision = undefined - } + if (landed) this.clearDraft() this.publish() } @@ -290,9 +300,12 @@ export class SubagentModelSelectionCardController { else this.publish() } - /** Clear Host-specific candidates and reload after reconnecting. */ - resetCatalog(): void { + /** Drop Host-specific candidates and drafts, then reload after reconnecting. */ + resetConnection(): void { if (this.disposed) return + this.saveGeneration += 1 + this.saving = false + this.clearDraft() this.catalogGroups = [] this.refreshCatalog() } @@ -301,7 +314,6 @@ export class SubagentModelSelectionCardController { if (this.disposed || this.catalogStatus === 'loading') return const generation = this.catalogGeneration this.catalogStatus = 'loading' - this.catalogGroups = [] this.catalogPartial = false this.publish() try { @@ -334,6 +346,7 @@ export class SubagentModelSelectionCardController { candidates: this.candidates(), catalogStatus: this.catalogStatus, catalogPartial: this.catalogPartial, + conflicted: this.conflicted, } } diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 9f0f00e93b..6d118856ef 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -187,7 +187,7 @@ describe('ui-settings-plugins apply', () => { it('refreshes the subagent catalog after model inputs change or the connection resets', async () => { const refresh = vi.spyOn(SubagentModelSelectionCardController.prototype, 'refreshCatalog') - const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetCatalog') + const reset = vi.spyOn(SubagentModelSelectionCardController.prototype, 'resetConnection') const { ctx, slots, remote } = await bench(['subagent-model-selection']) declareRoot(slots) await ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx index cf37eb05b5..e84a00cf49 100644 --- a/packages/client/ui-settings-plugins/tests/section.client.spec.tsx +++ b/packages/client/ui-settings-plugins/tests/section.client.spec.tsx @@ -93,6 +93,7 @@ function renderSubagentModelSelection(state: Partial { expect(screen.getByText(en.subagentModelSelectionEmpty)).toBeTruthy() }) + it('distinguishes a stale draft from a rejected save', () => { + renderSubagentModelSelection({ dirty: true, conflicted: true }) + fireEvent.click(screen.getByText(en.subagentModelSelectionTitle)) + + expect(screen.getByText(en.subagentModelSelectionConflict)).toBeTruthy() + expect(screen.queryByText(en.saveFailed)).toBeNull() + }) + it('stays hidden when unavailable and disables writes when read-only', () => { renderSubagentModelSelection({ available: false }) expect(screen.queryByText(en.subagentModelSelectionTitle)).toBeNull() diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 3e5002954d..28c26e73cd 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -614,17 +614,122 @@ describe('SubagentModelSelectionCardController', () => { revision: 5, value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, }) - expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ failed: true, dirty: true }) + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: true, failed: false, dirty: true, + }) face.save() await Promise.resolve() expect(host.mutate).not.toHaveBeenCalled() face.discard() expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ - failed: false, dirty: false, enabled: true, + conflicted: false, failed: false, dirty: false, enabled: true, }) }) + it('settles a draft when a newer Host revision already contains it', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + host.publish({ + revision: 5, + value: { enabled: true, allowedModels: [{ provider: 'alpha', model: 'fast' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + }) + + it('retains unsaved routes across a catalog refresh', async () => { + const host = stubSettingsScope() + acceptWrites(host) + host.publish({ + status: 'ready', writable: true, revision: 2, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const refreshed = deferred() + const models = vi.fn() + .mockResolvedValueOnce({ + rpcId: 'catalog-1', + result: { ok: true, value: { + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + failures: [], + } }, + }) + .mockImplementationOnce(() => refreshed.promise) + const controller = new SubagentModelSelectionCardController( + host.scope, { llm: { models } } as never, + ) + const face = controller.inject() + const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() + face.toggleEnabled() + await vi.waitFor(() => { expect(state().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.refreshCatalog() + expect(state()).toMatchObject({ + catalogStatus: 'loading', + candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })], + }) + refreshed.resolve({ + rpcId: 'catalog-2', + result: { ok: true, value: { groups: [], failures: [] } }, + } as never) + await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) + expect(state().candidates).toEqual([ + expect.objectContaining({ key: 'alpha\0fast', available: false, selected: true }), + ]) + + face.save() + await vi.waitFor(() => { + expect(host.mutate).toHaveBeenCalledWith([ + { op: 'set', path: ['enabled'], value: true }, + { op: 'set', path: ['allowedModels'], value: [{ provider: 'alpha', model: 'fast' }] }, + ], 2) + }) + }) + + it('drops a draft when the connection generation changes', async () => { + const host = stubSettingsScope() + const models = modelsApi({ + groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], + }) + host.publish({ + status: 'ready', writable: true, revision: 4, + value: { enabled: false, allowedModels: [] }, user: {}, + }) + const controller = new SubagentModelSelectionCardController(host.scope, models.api) + const face = controller.inject() + face.toggleEnabled() + await vi.waitFor(() => { expect(face.hooks.subagentModelSelectionCard.getSnapshot().candidates).toHaveLength(1) }) + face.toggleModel('alpha\0fast') + + controller.resetConnection() + host.publish({ + revision: 4, + value: { enabled: true, allowedModels: [{ provider: 'other', model: 'new' }] }, + }) + + expect(face.hooks.subagentModelSelectionCard.getSnapshot()).toMatchObject({ + conflicted: false, dirty: false, enabled: true, + }) + face.save() + await Promise.resolve() + expect(host.mutate).not.toHaveBeenCalled() + }) + it('reloads the model catalog after invalidation', async () => { const host = stubSettingsScope() host.publish({ @@ -739,7 +844,7 @@ describe('SubagentModelSelectionCardController', () => { controller.dispose() controller.refreshCatalog() - controller.resetCatalog() + controller.resetConnection() face.toggleEnabled() face.retryCatalog() face.save() diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index e34f464d28..660bf0ba4d 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: 5225aa28719e92b2158951aa145d9335a67ff1a2 -README.zh.md: 702cf46d36e15c7524c3dffc5dd46035547ff768 +README.md: 5520e98ddcdc4532a74cf7a8a812b9e60751eaf3 +README.zh.md: 069eca4f8c9dd0bf8fd939be2569c63f88c6be0b diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 5225aa2871..5520e98ddc 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -212,8 +212,8 @@ These limits define what this tool does not return or enforce; they are current - **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. -- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable the fields only when route changes preserve reuse or expose a bounded recomputation cost. -- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM provider/model/reasoning-effort selection requires an enabled per-Session preference and a subagent provider that advertises `agentOptions`; out-of-process providers currently reject enabling it rather than ignore it. +- **Shipped fork tools cannot select a child LLM route** — they inherit the parent's provider and model to keep the copied conversation prefix eligible for KV Cache reuse. Re-enable selection only when route changes preserve reuse or expose a bounded recomputation cost. +- **Non-routing child policy is fixed per instance** — another persona, tool filter, or depth cap requires another distinctly named tool. LLM selection requires an enabled per-Session preference and a provider that advertises `agentOptions`; both in-process providers and DSH SDK advertise it, while ACP, Codex, and Claude Code reject it rather than ignore it. ### Dev Note diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 702cf46d36..069eca4f8c 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -212,8 +212,8 @@ Use subagent in the background by default. Start independent delegations togethe - **后台运行不通过本工具公开结果**——一次性任务的最终输出通过通用 Task 接口收集,可继续子 agent 的输出留在其自身会话中,按其 subagent id 读取。结算通知会说明该子 agent 如何结束,并携带可能存在的最终 assistant 消息,但它不是本次调用的返回值,也无法在此等待。 - **等待中的一次性实例较晚才发现重复名称**(`TODO(subagent-dup-toolname)`)——可继续实例会在插件应用期间预留提示词 section 名称,但若要阻止等待中的一次性实例回滚提供方注册,仍需要一份预期名称注册表。 -- **随附 fork 工具无法选择子级 LLM 路由**:它们会继承父级的提供方与模型,使复制的对话前缀仍可供 KV Cache 复用。只有在路由变化仍能保留复用,或接口能公开一项有界的重算成本时,才重新启用这些字段。 -- **每个实例的非路由子 agent 策略固定**:其他 persona、工具过滤器或深度上限都需要另一个名称不同的工具。LLM 提供方/模型/推理强度选择要求每 Session 偏好已启用,并要求 subagent 提供方声明 `agentOptions`;进程外提供方目前会拒绝启用它,而不是忽略它。 +- **随附 fork 工具不能选择子级 LLM 路由**——它们继承父级提供方与模型,使复制的对话前缀仍有资格复用 KV Cache。仅当路由变更能保留复用或公开有界重算成本时,才重新启用选择。 +- **非路由子 agent 策略按实例固定**——另一个 persona、工具过滤器或深度上限需要另一个名称不同的工具。LLM 选择要求启用逐 Session 偏好,且提供方必须声明 `agentOptions`;两个进程内提供方和 DSH SDK 会声明该能力,而 ACP、Codex 与 Claude Code 会拒绝它,而不是忽略它。 ### 开发备注 From db1436137241d84cc1eeaa9e45d5a57ac1dbef6d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 12:10:52 +0800 Subject: [PATCH 049/130] test(web): scope the aria age normalizer to the region that needs it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing every relative-time bucket to `{{age}}` reached the session-tree goldens, where a literal age is the assertion: a fresh row reads `now` and an older one does not. Six e2e files failed, two of them by aborting mid-scenario and leaving their replay fixtures half-consumed. `captureStableAria` now takes the rule as an opt-in, and only the reference menu — whose rows are dated from the live Host list — asks for it. Refs #3154 --- apps/web/tests/reference-composer.e2e.ts | 6 +++- apps/web/tests/scaffold.ts | 38 ++++++++++++++++-------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index 2ea7eed5e4..ed4e0ed4aa 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -147,7 +147,11 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await input.fill('@') await expect.poll(() => menu.getByRole('option').count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(2) - const snapshot = await captureStableAria(page, '[role="listbox"]', scaffold.workspaceCwd) + // Session rows are dated from the live Host list, so their age bucket + // advances while the suite runs. + const snapshot = await captureStableAria( + page, '[role="listbox"]', scaffold.workspaceCwd, { normalizeAge: true }, + ) await compareOrRefreshGolden(MENU_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Files & folders') expect(snapshot).toContain('Sessions') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index b43d13ae28..7b725ae64c 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1094,22 +1094,26 @@ async function persistSeedSession( * on one machine (measured 69 → 70 tok/s) and swings wildly on a fast replay * (26333 tok/s for a 3 ms stream). */ -function normalizeAria(snapshot: string, workspaceCwd: string): string { +/** + * Relative-time buckets rendered by a dated row, in both dictionaries. + * + * Opt-in per capture: a session-tree golden asserts its own literal age (a + * fresh row reads `now`, an older one does not), so collapsing the vocabulary + * everywhere would delete that assertion. A region whose rows are dated from + * live wall-clock state asks for it instead. Anchored on an aria label's + * closing quote, where the bucket is always last. + */ +const ARIA_AGE = + /(?:now|\d+min|\d+h|\d+d|\d+mo|\d+y|刚刚|\d+分钟|\d+小时|\d+天|\d+个月|\d+年)(?=")/g + +function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): string { // The session heading renders the workspace's basename, not the full // path, so both spellings must collapse to the token. const base = workspaceCwd.split('/').pop()! - return snapshot + return (age ? snapshot.replace(ARIA_AGE, '{{age}}') : snapshot) .split(workspaceCwd).join('{{cwd}}') .split(base).join('{{workspace}}') .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') - // Rows dating a session render the shared relative-time bucket, which - // advances while the suite runs. Anchored on an aria label's closing - // quote, where the bucket is always last, and applied before the duration - // rules so the whole vocabulary collapses to one token. - .replace( - /(?:now|\d+min|\d+h|\d+d|\d+mo|\d+y|刚刚|\d+分钟|\d+小时|\d+天|\d+个月|\d+年)(?=")/g, - '{{age}}', - ) // The optional space in `\d+m ?\d+s` covers both minute spellings: the // stats line's compact `2m42s` and the message-chrome template's `2m 42s`. .replace( @@ -1141,13 +1145,21 @@ function normalizeAria(snapshot: string, workspaceCwd: string): string { * @param page - the page under test. * @param selector - the region locator selector. * @param workspaceCwd - normalization input. + * @param options - `normalizeAge` collapses relative-time buckets to `{{age}}` + * for a region whose rows are dated from live wall-clock state. * @returns the stable normalized snapshot. */ -export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise { +export async function captureStableAria( + page: Page, + selector: string, + workspaceCwd: string, + options: { normalizeAge?: boolean } = {}, +): Promise { const region = page.locator(selector).first() - let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd) + const age = options.normalizeAge === true + let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) await expect.poll(async () => { - const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd) + const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd, age) const stable = current === previous previous = current return stable From e49e7202c17ba11dc9516aa64c3a06ba03b178dd Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 27 Aug 2026 12:18:05 +0800 Subject: [PATCH 050/130] fix: align model selection with current settings remotes --- .../client/ui-settings-plugins/package.json | 3 + ...ubagent-model-selection-card-controller.ts | 6 +- .../tests/settings-scope.client.spec.ts | 15 +-- .../subagent/tool-subagent/tests/harness.ts | 29 +++-- .../tool-subagent/tests/list-models.spec.ts | 7 ++ .../tests/model-selection-settings.spec.ts | 4 +- .../tests/model-selection.spec.ts | 12 +- .../tool-subagent/tests/tool-subagent.spec.ts | 116 ++++++------------ pnpm-lock.yaml | 3 + .../cancel-tool-calls/stdout.expected.jsonl | 7 ++ .../ralph-loop/tool-schemas.1.expected.json | 31 +---- .../ralph-loop/tool-schemas.2.expected.json | 31 +---- .../snapshot.yml | 12 -- 13 files changed, 110 insertions(+), 166 deletions(-) create mode 100644 snapshots/acp/cancel-tool-calls/stdout.expected.jsonl delete mode 100644 snapshots/session/subagent-configured-effort-rejection/snapshot.yml diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 75b87d26fb..7edd06ff4e 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-api-remotes" @@ -47,6 +48,7 @@ "peerDependencies": { "@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-ui-settings": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -55,6 +57,7 @@ "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-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 74917edc56..053074a1ff 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -280,7 +280,11 @@ export class SubagentModelSelectionCardController { this.publish() await this.scope.mutate([ { op: 'set', path: ['enabled'], value: desiredEnabled }, - { op: 'set', path: ['allowedModels'], value: desired }, + { + op: 'set', + path: ['allowedModels'], + value: desired.map(route => ({ provider: route.provider, model: route.model })), + }, ], this.draftRevision) if (generation !== this.saveGeneration) return const landed = this.currentEnabled() === desiredEnabled && sameRoutes(this.currentRoutes(), desired) diff --git a/packages/client/ui-settings/tests/settings-scope.client.spec.ts b/packages/client/ui-settings/tests/settings-scope.client.spec.ts index fe0d4013ee..25091897f0 100644 --- a/packages/client/ui-settings/tests/settings-scope.client.spec.ts +++ b/packages/client/ui-settings/tests/settings-scope.client.spec.ts @@ -188,7 +188,7 @@ describe('SettingsScopeController', () => { const write = scope.mutate(ops) ops[0] = { op: 'unset', path: ['enabled'] } - ;(ops[1] as { value: Array<{ model: string }> }).value[0]!.model = 'changed' + ;(ops[1] as unknown as { value: Array<{ model: string }> }).value[0]!.model = 'changed' await write expect(mutate).toHaveBeenCalledWith( @@ -202,7 +202,7 @@ describe('SettingsScopeController', () => { }) it('preserves an editor-owned revision fence behind earlier queued writes', async () => { - const first = deferred>() + const first = deferred>() const describeCall = vi.fn() .mockResolvedValueOnce(described({ preference: 'system' }, 7)) .mockResolvedValueOnce(described({ preference: 'dark' }, 8)) @@ -217,11 +217,12 @@ describe('SettingsScopeController', () => { first.resolve(ok(view({ preference: 'dark' }, 8))) await Promise.all([earlier, fenced]) - expect(mutate).toHaveBeenNthCalledWith(2, { - ns: 'ui-test', - ops: [{ op: 'set', path: ['preference'], value: 'light' }], - expectedRevision: 7, - }) + expect(mutate).toHaveBeenNthCalledWith( + 2, + 'ui-test', + [{ op: 'set', path: ['preference'], value: 'light' }], + 7, + ) expect(scope.getSnapshot()).toMatchObject({ value: { preference: 'dark' }, revision: 8 }) }) diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 6fe8b866e4..91632402d6 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -2,7 +2,7 @@ import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { ToolCallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import SubagentRuntime from '@deepseek-ai/dsh-subagent' @@ -22,14 +22,18 @@ export function fakeAgent(id = 'parent-1'): Agent { /** Mount the real tool and service stack around one scripted subagent provider. */ const setupAgents = new WeakMap() +const setupProviders = new WeakMap>>() let setupAgentCounter = 0 /** Test-only opt-in translated to the real Host setting and Session path. */ -type SetupConfig = tool.Config & { withModelSelection?: boolean } +type SetupConfig = tool.Config & { + withModelSelection?: boolean + parentAgentOptions?: AgentOptions +} const TEST_ALLOWED_MODELS = [ - 'allowed-model', 'configured-model', 'current-model', 'fast-model', 'other-model', - 'parent-model', 'unlisted-model', + 'allowed-model', 'child-model', 'configured-model', 'current-model', 'fast-model', + 'other-model', 'parent-model', 'selected-model', 'unlisted-model', ].flatMap(model => [ { provider: 'alpha', model }, { provider: 'current-provider', model }, @@ -38,7 +42,7 @@ const TEST_ALLOWED_MODELS = [ export async function setup(toolConfig: SetupConfig, mockConfig: Partial = {}): Promise { const ctx = new Context() - const { withModelSelection, ...config } = toolConfig + const { withModelSelection, parentAgentOptions, ...config } = toolConfig if (withModelSelection === true) { await ctx.plugin(SubagentModelSelectionConfig, { enabled: true, @@ -47,9 +51,11 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial { await agentCtx.plugin(tool, { ...config, modelSelectionSettings: true }) }, @@ -61,11 +67,20 @@ export async function setup(toolConfig: SetupConfig, mockConfig: Partial { + const provider = setupProviders.get(ctx) + if (provider === undefined) throw new Error('context has no setup provider') + setupProviders.delete(ctx) + await provider.dispose() +} + /** Return the real Agent created for a settings-controlled setup. */ export function modelSelectionSetupAgent(ctx: Context): Agent { const agent = setupAgents.get(ctx) diff --git a/packages/subagent/tool-subagent/tests/list-models.spec.ts b/packages/subagent/tool-subagent/tests/list-models.spec.ts index d1739dcce8..308fe47f8e 100644 --- a/packages/subagent/tool-subagent/tests/list-models.spec.ts +++ b/packages/subagent/tool-subagent/tests/list-models.spec.ts @@ -238,4 +238,11 @@ describe('list_subagent_models', () => { expect(text(result)).toContain('available providers: alpha') expect(text(result)).not.toContain('secret') }) + + it('reports no available provider when the authorized registry intersection is empty', async () => { + const ctx = await setupListTool([{ provider: 'missing', model: 'fast' }]) + const result = await call(ctx, { provider: 'missing' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('available providers: (none)') + }) }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 4078a828b7..ac1a103d11 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { bindScopeParent, createScope, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' @@ -171,7 +171,7 @@ describe('SubagentModelSelectionConfig', () => { const result = await ctx.tools.execute({ signal: new AbortController().signal, - callId: CallId('disallowed-session-route'), + callId: ToolCallId('disallowed-session-route'), name: 'subagent', arguments: { description: 'forced route', diff --git a/packages/subagent/tool-subagent/tests/model-selection.spec.ts b/packages/subagent/tool-subagent/tests/model-selection.spec.ts index 9137e2a9dd..b9565975bb 100644 --- a/packages/subagent/tool-subagent/tests/model-selection.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection.spec.ts @@ -10,7 +10,11 @@ import { Session, SessionId } from '@deepseek-ai/dsh-session' import { MockAdapter } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' -import { assertAllowedModelRoutes, assertAllowedModelSelection } from '../src/model-selection.ts' +import { + assertAllowedModelRoutes, + assertAllowedModelSelection, + preflightChildLlmRoute, +} from '../src/model-selection.ts' import { callSubagent, modelSelectionSetupAgent, setup, text } from './harness.ts' const REASONING = { @@ -284,6 +288,12 @@ describe('dsh-tool-subagent model selection', () => { expect(text(result)).toContain('without an effective provider and model') }) + it('rejects preflight without an effective provider and model', async () => { + const ctx = await setup({ provider: 'mock' }) + await expect(preflightChildLlmRoute(ctx.llm, {}, undefined, AbortSignal.abort())) + .rejects.toThrow('without an effective provider and model') + }) + it.each([ { provider: 'alpha' }, { model: 'fast-model' }, diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index cff7140a25..27c250a225 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' -import LlmRuntime, { ToolCallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { ToolCallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' @@ -21,7 +21,15 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import { callSubagent, fakeAgent, setup, testToolSignal, text } from './harness.ts' +import { + callSubagent, + disposeSetupProvider, + fakeAgent, + modelSelectionSetupAgent, + setup, + testToolSignal, + text, +} from './harness.ts' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -221,36 +229,19 @@ describe('dsh-tool-subagent', () => { }) it('merges model overrides over provider-owned route defaults before preflight', async () => { - let seen: { agentOptions?: { provider?: string; model?: string; reasoningEffort?: string; maxTokens?: number } } | undefined - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - ctx.subagents.registerProvider({ - name: 'capture', - capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, + let seen: SubagentStartRequest | undefined + const ctx = await setup({ + provider: 'mock', + withModelSelection: true, + agentOptions: { reasoningEffort: ReasoningEffortId('high'), maxTokens: 321 }, + maxDepth: 'provider-managed', + }, { agentRouteDefaults: { provider: 'alpha', model: 'child-model' }, - start: async (request) => { - seen = request - return { - id: SessionId('capture-child'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - dispose: async () => {}, - } - }, + onStart: (request) => { seen = request }, }) ctx.llm.registerAdapter(['alpha'], new MockAdapter([], { efforts: [{ id: ReasoningEffortId('high'), name: 'High' }], })) - await ctx.plugin(tool, { - provider: 'capture', - enableModelSelection: true, - agentOptions: { reasoningEffort: ReasoningEffortId('high'), maxTokens: 321 }, - maxDepth: 'provider-managed', - }) await callSubagent(ctx, { description: 'd', @@ -258,7 +249,7 @@ describe('dsh-tool-subagent', () => { provider: 'alpha', model: 'child-model', }) - expect(ctx.tools.schemas().find(schema => schema.name === 'subagent')?.description) + expect(ctx.tools.schemas(modelSelectionSetupAgent(ctx)).find(schema => schema.name === 'subagent')?.description) .toContain('this provider\'s route defaults') expect(seen?.agentOptions).toEqual({ provider: 'alpha', @@ -270,40 +261,21 @@ describe('dsh-tool-subagent', () => { it('does not inherit parent effort for a provider-owned route default', async () => { let seen: SubagentStartRequest | undefined - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - ctx.subagents.registerProvider({ - name: 'provider-defaults', - capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - agentRouteDefaults: { provider: 'alpha', model: 'child-model' }, - start: async (request) => { - seen = request - return { - id: SessionId('provider-default-child'), - localAgent: undefined, - result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), - dispose: async () => {}, - } - }, - }) - ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) - await ctx.plugin(tool, { - provider: 'provider-defaults', - enableModelSelection: true, - maxDepth: 'provider-managed', - }) - const parent = { - ...fakeAgent('same-route-parent'), - options: { + const ctx = await setup({ + provider: 'mock', + withModelSelection: true, + parentAgentOptions: { provider: 'alpha', model: 'child-model', reasoningEffort: ReasoningEffortId('high'), }, - } as Agent + maxDepth: 'provider-managed', + }, { + agentRouteDefaults: { provider: 'alpha', model: 'child-model' }, + onStart: (request) => { seen = request }, + }) + ctx.llm.registerAdapter(['alpha'], new MockAdapter([])) + const parent = modelSelectionSetupAgent(ctx) const result = await callSubagent(ctx, { description: 'd', @@ -312,6 +284,7 @@ describe('dsh-tool-subagent', () => { model: 'child-model', }, { agent: parent }) + if (result.isError) throw new Error(text(result)) expect(result.isError).toBe(false) expect(seen?.agentOptions).toEqual({ provider: 'alpha', model: 'child-model' }) }) @@ -994,24 +967,15 @@ describe('dsh-tool-subagent background mode', () => { }) it('rejects startup when the provider changes during asynchronous route preflight', async () => { - const ctx = new Context() - await ctx.plugin(LlmRuntime) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - await ctx.plugin(SubagentRuntime) - const oldStart = vi.fn(async (): Promise => { throw new Error('old provider must not start') }) + const oldStart = vi.fn() const replacementStart = vi.fn(async (): Promise => { throw new Error('replacement provider must not start') }) - const disposeOld = ctx.subagents.registerProvider({ - name: 'swapped', - capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - agentRouteDefaults: { provider: 'alpha', model: 'selected-model' }, - start: oldStart, - }) - await ctx.plugin(tool, { - provider: 'swapped', - enableModelSelection: true, + const ctx = await setup({ + provider: 'mock', + withModelSelection: true, maxDepth: 'provider-managed', + }, { + agentRouteDefaults: { provider: 'alpha', model: 'selected-model' }, + onStart: oldStart, }) const adapter = new MockAdapter([]) let releasePreflight!: () => void @@ -1029,9 +993,9 @@ describe('dsh-tool-subagent background mode', () => { model: 'selected-model', }) await vi.waitFor(() => { expect(resolveModel).toHaveBeenCalledOnce() }) - disposeOld() + await disposeSetupProvider(ctx) ctx.subagents.registerProvider({ - name: 'swapped', + name: 'mock', capabilities: { agentOptions: true, outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, agentRouteDefaults: { provider: 'beta', model: 'replacement-model' }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f271832de3..0a1cf4046a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3127,6 +3127,9 @@ importers: '@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-locale': specifier: workspace:^ version: link:../locale diff --git a/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl b/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl new file mode 100644 index 0000000000..0b49c17e82 --- /dev/null +++ b/snapshots/acp/cancel-tool-calls/stdout.expected.jsonl @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"node -e \"const fs=require('node:fs'); fs.writeFileSync('started.tmp', 'started'); fs.renameSync('started.tmp', 'started.txt'); setInterval(() => {}, 1000)\"","description":"Wait until cancellation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf skipped > skipped.txt","description":"Write skipped marker"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted before dispatch"}}]}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/snapshots/session/ralph-loop/tool-schemas.1.expected.json b/snapshots/session/ralph-loop/tool-schemas.1.expected.json index 54d0732db7..dbcc5636e5 100644 --- a/snapshots/session/ralph-loop/tool-schemas.1.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.1.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -504,7 +487,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -516,18 +499,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/ralph-loop/tool-schemas.2.expected.json b/snapshots/session/ralph-loop/tool-schemas.2.expected.json index 54d0732db7..dbcc5636e5 100644 --- a/snapshots/session/ralph-loop/tool-schemas.2.expected.json +++ b/snapshots/session/ralph-loop/tool-schemas.2.expected.json @@ -260,23 +260,6 @@ } } }, - { - "name": "list_subagent_models", - "description": "Discover LLM routes for subagents without changing the current Agent. Call with no arguments to list registered providers, with `provider` to list its advertised models, or with `provider` and `model` to inspect that exact model and its reasoning efforts. Catalog membership is advisory: an adapter may accept an unlisted model id. Use the returned ids with a delegation tool's `provider`, `model`, and `reasoning_effort` fields.", - "parameters": { - "type": "object", - "properties": { - "provider": { - "type": "string", - "description": "Registered LLM provider id. Omit to list providers." - }, - "model": { - "type": "string", - "description": "Exact model id to inspect. Requires provider; omit to list that provider's advertised models." - } - } - } - }, { "name": "ralph", "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", @@ -504,7 +487,7 @@ }, { "name": "subagent", - "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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. Child LLM selection is optional. Omit `provider`, `model`, and `reasoning_effort` to use configured child defaults and inherit compatible missing values from the parent Agent. Supply `provider` and `model` together after using `list_subagent_models` to inspect advertised routes and efforts. Changing the effective route without naming an effort uses the selected model's default effort.", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. 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.", "parameters": { "type": "object", "properties": { @@ -516,18 +499,6 @@ "type": "string", "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." }, - "provider": { - "type": "string", - "description": "LLM provider route for the child. Supply together with model; omit both to use configured child defaults or inherit the parent route." - }, - "model": { - "type": "string", - "description": "Model id interpreted by provider. Supply together with provider; omit both to use configured child defaults or inherit the parent route." - }, - "reasoning_effort": { - "type": "string", - "description": "Adapter-owned reasoning effort for the effective child route. Omit to inherit a compatible configured/parent effort or use a newly selected model's default." - }, "run_in_background": { "type": "boolean", "description": "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." diff --git a/snapshots/session/subagent-configured-effort-rejection/snapshot.yml b/snapshots/session/subagent-configured-effort-rejection/snapshot.yml deleted file mode 100644 index 327eb8de66..0000000000 --- a/snapshots/session/subagent-configured-effort-rejection/snapshot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 1 -scenario: subagent-configured-effort-rejection -profile: headless -composition: subagent-configured-effort -recording: authored -header: - class: subagent-configured-effort - pin: true - systemPromptSource: text-turn - toolSchemasSource: text-turn -replay: - override: true From 32ddfcd89c0f7de9a86f7fe135000a971d6b3682 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:06:12 +0800 Subject: [PATCH 051/130] fix(subprocess): read the process table once per terminal poll MacProcessInspector answered the descendant tree and every member's liveness with its own `/bin/ps` fork, so one readiness poll cost N+1 full table reads for N tracked descendants. With execFileSync on that path and a 50 ms poll interval, any command spawning two or more children saturated the host event loop until it exited. ProcessInspector.snapshot() now returns one ProcessSnapshot that answers tree, session, and alive from a single observation, and signalProcess takes the caller's observation so its PID-reuse fence does not re-read the table per member. --- ...26-08-27-process-table-snapshots.i18n.yaml | 6 + .../2026-08-27-process-table-snapshots.md | 67 ++++++++ .../2026-08-27-process-table-snapshots.zh.md | 67 ++++++++ .../subprocess-local/src/process-inspector.ts | 151 +++++++++++++----- .../subprocess-local/src/terminal.ts | 46 +++--- .../subprocess-local/src/windows-inspector.ts | 30 ++-- .../subprocess-local/tests/local.spec.ts | 12 +- .../tests/process-exit.spec.ts | 5 +- .../tests/process-inspector.spec.ts | 38 +++-- .../subprocess-local/tests/terminal.spec.ts | 89 +++++++++-- .../tests/windows-inspector.spec.ts | 28 ++-- .../terminal-bash/tests/session.spec.ts | 10 +- 12 files changed, 417 insertions(+), 132 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md create mode 100644 .agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml new file mode 100644 index 0000000000..01a60f2417 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.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-27-process-table-snapshots.md +2026-08-27-process-table-snapshots.md: 2f031cc2952ffe1c007e04acf4dabbeac0630630 +2026-08-27-process-table-snapshots.zh.md: fbad15c306d250aeb64247a301ed20777f39c4a3 diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md new file mode 100644 index 0000000000..2f031cc295 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md @@ -0,0 +1,67 @@ +# Agent Note: Process-table snapshots replace per-question inspector reads + +Status: implemented + +English | [中文](2026-08-27-process-table-snapshots.zh.md) + +## Problem + +A terminal readiness poll asks the platform three questions: the shell's descendant tree, its POSIX session membership, and whether each tracked descendant is still running. When each question reads the process table independently, the poll's cost scales with the number of descendants the running command spawned. + +On macOS each read is a `/bin/ps -axo` fork that parses the entire table — 14.33 ms for 795 processes on the measured host. `LocalTerminalHandle.inspectForeground()` reads the tree once and then asks liveness once per tracked descendant, so one poll costs N+1 table reads for N descendants. `dsh-terminal-bash` polls every 50 ms for up to 30 s, and `ProcessInspectorInternals.exec` is `execFileSync`, so each poll blocks the event loop for its full duration. + +Measured by driving the production `MacProcessInspector` against a real process tree: + +| tracked descendants | one poll | share of the 50 ms interval | +|---|---|---| +| 0 | 18.0 ms | 36% | +| 1 | 33.8 ms | 68% | +| 2 | 49.1 ms | 98% | +| 5 | 87.1 ms | 174% | +| 10 | 178.4 ms | 357% | + +Any command spawning two or more children — a pipeline, `make`, `pnpm`, `git` — saturates the host event loop until it exits. + +Teardown has the same structure. `signalProcess` fences each signal against PID reuse by asking liveness itself, so signalling N members costs N table reads. + +## Decision + +`ProcessInspector.snapshot()` returns a `ProcessSnapshot`, one observation of the process table that answers `tree(rootPid)`, `session(sessionId)`, and `alive(identity)`. It replaces the three per-question methods; the inspector's remaining surface is `foregroundPgid`, `isStdinWaiting`, `signalGroup`, and `signalProcess`. + +Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change. + +`signalProcess(identity, signal, observed)` takes the caller's observation rather than reading the table itself. The PID-reuse fence stays, and `signalMembers` now captures once for a whole signalling round instead of once per member. Passing the observation explicitly is what keeps Linux teardown from regressing: `alive` there is answered from a `/proc` walk the snapshot already paid for, not from a fresh walk per member. + +Platform differences live in how a snapshot is built, not in what it promises: + +- **macOS** builds it from one `ps` table. That table exposes neither a session id nor a state column, so `session` is empty and `alive` reports presence with a matching start identity. +- **Linux** walks `/proc` once, carrying each entry's parent, start identity, session, and state. `alive` treats the `Z`, `X`, and `x` states as quiescent, as a per-pid `stat` read did. +- **Windows** captures the Toolhelp32 enumeration for `tree`, has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there. + +`PosixProcessSnapshot` holds both POSIX shapes: a row's `session` and `state` are `undefined` where the platform's table omits them, which is what makes the macOS answers fall out of the shared implementation instead of a second class. + +## Testing + +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` drives a real `MacProcessInspector` over an injected `exec` and asserts one foreground inspection performs exactly one `-axo` table read at 0, 2, and 10 descendants. That count, not wall time, is the durable invariant: it holds on any host and fails the moment a caller re-reads the table per member. + +## Alternatives considered + +**A batched `aliveMembers(members)` call, leaving the other methods alone.** This collapses the per-member reads and is a much smaller edit, but the tree read stays separate, so a macOS poll still forks `ps` twice plus the `tpgid` read — about 32 ms at 10 descendants, still 64% of the 50 ms interval. The event loop remains mostly blocked, so the measured problem survives the fix. + +**Caching the macOS table inside `MacProcessInspector` behind a short TTL.** This needs no interface change, but it makes staleness invisible: a caller cannot tell whether a liveness answer came from this instant or from the end of the previous poll, and a signal decided on a stale row is exactly what the PID-reuse fence exists to prevent. Hidden caching also conflicts with the repository's preference for explicit defaulting and explicit boundaries. + +**Keeping `isAlive` on the inspector next to `snapshot()`.** This avoids touching the signalling call sites, at the cost of two ways to ask one question, where only one of them is cheap in a loop. The asymmetry would have to be re-explained at every call site. + +**Making `exec` asynchronous instead of reducing the read count.** An async `execFile` stops the poll from blocking the loop but still forks N+1 processes per poll; on a busy machine that trades a stall for sustained fork pressure. It remains a worthwhile follow-up on top of the reduced count, not a substitute for it. + +## Consequences + +A readiness poll's process-table cost is now constant in descendant count. On macOS one poll performs one full table read plus the small `tpgid` read, which is the 0-descendant cost in the table above for every descendant count. + +Liveness for a single identity on Linux costs a full `/proc` walk rather than one `stat` read. Every caller that asks about several identities amortizes that walk across them, which is why `signalProcess` takes an observation rather than capturing its own; a future caller that genuinely needs one isolated liveness answer pays more than it did. + +A snapshot is a point-in-time view, and the type's documentation says so. Holding one across an `await` and then signalling from it would widen the PID-reuse window that the fence narrows; `waitForMembers` re-captures per iteration for exactly this reason. + +Every `ProcessInspector` implementation and test fake carries the new shape, including the Windows inspector and the `dsh-terminal-bash` session fake. Test fakes that previously replaced `processTree`, `processSession`, or `isAlive` to stage a scan now replace the corresponding per-question read hook, which keeps their staging behavior and call-counting identical. + +The synchronous `execFileSync` boundary and the fixed 50 ms poll interval are unchanged; both remain open follow-ups for the same readiness path. diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md new file mode 100644 index 0000000000..fbad15c306 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md @@ -0,0 +1,67 @@ +# Agent Note: Process-table snapshots replace per-question inspector reads + +Status: implemented + +[English](2026-08-27-process-table-snapshots.md) | 中文 + +## Problem + +一次终端就绪轮询要向平台问三个问题:shell 的子进程树、它的 POSIX 会话成员、以及每个被跟踪的子进程是否仍在运行。当每个问题各自去读一次进程表时,这次轮询的代价就随着当前命令派生出的子进程数量增长。 + +在 macOS 上每一次读取都是一次 `/bin/ps -axo` fork,并解析整张表——在实测主机上 795 个进程需要 14.33 ms。`LocalTerminalHandle.inspectForeground()` 读一次树,然后按每个被跟踪的子进程各问一次存活,所以 N 个子进程的一次轮询要读 N+1 次表。`dsh-terminal-bash` 每 50 ms 轮询一次、最长 30 s,而 `ProcessInspectorInternals.exec` 是 `execFileSync`,因此每次轮询在其整个时长内阻塞事件循环。 + +用生产环境的 `MacProcessInspector` 驱动真实进程树实测: + +| 被跟踪的子进程数 | 一次轮询 | 占 50 ms 间隔的比例 | +|---|---|---| +| 0 | 18.0 ms | 36% | +| 1 | 33.8 ms | 68% | +| 2 | 49.1 ms | 98% | +| 5 | 87.1 ms | 174% | +| 10 | 178.4 ms | 357% | + +任何派生两个及以上子进程的命令——一条管道、`make`、`pnpm`、`git`——都会把宿主事件循环打满,直到它退出。 + +拆卸路径的结构相同。`signalProcess` 自己去问存活来给每个信号加 PID 复用围栏,因此向 N 个成员发信号要读 N 次表。 + +## Decision + +`ProcessInspector.snapshot()` 返回一个 `ProcessSnapshot`,即对进程表的一次观察,由它回答 `tree(rootPid)`、`session(sessionId)` 和 `alive(identity)`。它取代了那三个按问题划分的方法;检查器剩下的接口是 `foregroundPgid`、`isStdinWaiting`、`signalGroup` 和 `signalProcess`。 + +每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。`waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。 + +`signalProcess(identity, signal, observed)` 接收调用方的观察,而不是自己去读表。PID 复用围栏保留,而 `signalMembers` 现在为整轮信号只捕获一次,而不是每个成员各一次。把观察显式传入正是 Linux 拆卸不退化的原因:那里的 `alive` 由快照已经付过代价的一次 `/proc` 遍历回答,而不是每个成员各遍历一次。 + +平台差异体现在快照如何构建,而不在它承诺什么: + +- **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。 +- **Linux** 遍历一次 `/proc`,携带每个条目的父进程、起始标识、会话与状态。`alive` 把 `Z`、`X`、`x` 状态视为静止,与按 pid 读 `stat` 的判定一致。 +- **Windows** 捕获 Toolhelp32 枚举供 `tree` 使用,没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列。 + +`PosixProcessSnapshot` 同时承载两种 POSIX 形态:当平台的表省略某字段时,该行的 `session` 与 `state` 为 `undefined`,这使得 macOS 的答案从共享实现中自然得出,而不必新增一个类。 + +## Testing + +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。 + +## Alternatives considered + +**只加一个批量的 `aliveMembers(members)`,其余方法不动。** 这能合并按成员的读取,改动也小得多,但树的读取仍然独立,因此 macOS 上一次轮询仍要 fork 两次 `ps` 外加 `tpgid` 读取——10 个子进程时约 32 ms,仍占 50 ms 间隔的 64%。事件循环依旧大部分时间被阻塞,实测到的问题在修复之后依然存在。 + +**在 `MacProcessInspector` 内部用短 TTL 缓存 macOS 的表。** 这不需要改接口,但它让陈旧性不可见:调用方无法分辨一个存活答案来自此刻还是来自上一次轮询结束时,而基于陈旧行发出的信号正是 PID 复用围栏要防止的事情。隐式缓存也与仓库偏好显式默认与显式边界的立场冲突。 + +**在 `snapshot()` 旁保留 `isAlive`。** 这样不必改动发信号的调用点,代价是同一个问题有两种问法,而其中只有一种在循环里是廉价的。这种不对称将不得不在每个调用点重新解释一遍。 + +**把 `exec` 改成异步,而不是减少读取次数。** 异步的 `execFile` 能让轮询不再阻塞事件循环,但每次轮询仍然 fork N+1 个进程;在繁忙的机器上这是把一次停顿换成了持续的 fork 压力。它在减少读取次数之上仍是值得做的后续项,而不是它的替代。 + +## Consequences + +一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。 + +Linux 上查询单个标识的存活,代价从读一个 `stat` 文件变成一次完整的 `/proc` 遍历。每个要查询多个标识的调用方都会把这次遍历摊薄,这正是 `signalProcess` 接收观察而非自行捕获的原因;将来若有调用方确实只需要一次孤立的存活查询,它付出的代价会比过去高。 + +快照是一个时间点视图,该类型的文档也这样声明。跨 `await` 持有一份快照再据此发信号,会扩大围栏本来要收窄的 PID 复用窗口;`waitForMembers` 每轮重新捕获正是为此。 + +每个 `ProcessInspector` 实现与测试替身都采用新形态,包括 Windows 检查器和 `dsh-terminal-bash` 的会话替身。此前通过替换 `processTree`、`processSession` 或 `isAlive` 来编排扫描的测试替身,现在替换对应的按问题读取钩子,其编排行为与调用计数保持不变。 + +同步的 `execFileSync` 边界与固定的 50 ms 轮询间隔未做改动;两者都仍是同一条就绪路径上待办的后续项。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 7a74213baf..1c89e8b5f1 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -16,6 +16,41 @@ interface FileStatus { isCharacterDevice(): boolean } +/** + * One observation of the platform process table, shared by every question a + * single readiness poll or teardown pass asks. + * + * The table is read once, at capture — a `/bin/ps` fork on macOS, a `/proc` + * walk on Linux, a Toolhelp32 enumeration on Windows. Answering {@link tree}, + * {@link session}, or {@link alive} never re-reads it, which is what keeps a + * poll's cost independent of how many descendants the running command spawned. + * Windows liveness additionally consults the live process handle, because wait + * state is not a table column there. + * + * A snapshot is a point-in-time view. Take a fresh one per poll or teardown + * pass; a stale one must never decide that a process is still worth signalling. + */ +export interface ProcessSnapshot { + /** + * Return the root and its transitive descendants as observed, children first. + * @param rootPid - tree root to descend from. + * @returns Observed root and descendants, children before parents. + */ + tree(rootPid: number): ProcessIdentity[] + /** + * Return observed members of one POSIX process session. + * @param sessionId - POSIX session identifier. + * @returns Observed session members, empty where the platform's table omits session ids. + */ + session(sessionId: number): ProcessIdentity[] + /** + * Return whether the exact identity was a non-quiescent process. + * @param identity - PID plus start identity to match. + * @returns Whether that exact identity — not merely that PID — was running. + */ + alive(identity: ProcessIdentity): boolean +} + /** Injectable OS process operations used by one local PTY session. */ export interface ProcessInspector { foregroundPgid(shellPid: number): number | undefined @@ -27,14 +62,19 @@ export interface ProcessInspector { * @returns Whether a group member is blocked reading the shell's terminal input. */ isStdinWaiting(pgid: number, shellPid: number): boolean - /** Return the root and its current transitive descendants, children first. */ - processTree(rootPid: number): ProcessIdentity[] - /** Return current members of one POSIX process session when the platform exposes them. */ - processSession(sessionId: number): ProcessIdentity[] - /** Return whether the exact identity remains a non-quiescent process. */ - isAlive(identity: ProcessIdentity): boolean + /** + * Read the process table once and answer tree, session, and liveness from it. + * @returns A point-in-time process-table observation. + */ + snapshot(): ProcessSnapshot signalGroup(pgid: number, signal: SubprocessTerminalSignal): void - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void + /** + * Signal one exact process identity, fenced against PID reuse. + * @param identity - PID plus start identity to signal. + * @param signal - termination signal to deliver. + * @param observed - observation the identity fence reads; pass one taken for this teardown pass. + */ + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void } /** Testable boundary around filesystem, process-table, and signal syscalls. */ @@ -292,16 +332,14 @@ abstract class PosixProcessInspector implements ProcessInspector { abstract foregroundPgid(shellPid: number): number | undefined abstract isStdinWaiting(pgid: number, shellPid: number): boolean - abstract processTree(rootPid: number): ProcessIdentity[] - abstract processSession(sessionId: number): ProcessIdentity[] - abstract isAlive(identity: ProcessIdentity): boolean + abstract snapshot(): ProcessSnapshot signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { this.internals.kill(-pgid, signal) } - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { - if (this.isAlive(identity)) this.internals.kill(identity.pid, signal) + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void { + if (observed.alive(identity)) this.internals.kill(identity.pid, signal) } } @@ -309,6 +347,42 @@ interface ProcessTreeEntry extends ProcessIdentity { parentPid: number } +/** One process-table row, carrying the fields a platform's table exposes. */ +interface ProcessRow extends ProcessTreeEntry { + /** POSIX session identifier, or undefined where the table omits it. */ + session: number | undefined + /** Single-letter process state, or undefined where the table omits it. */ + state: string | undefined +} + +// Zombie and dead states answer "present in the table" but never "still +// running"; a table without a state column can only report presence. +function quiescent(state: string | undefined): boolean { + return state !== undefined && /^[ZXx]$/.test(state) +} + +class PosixProcessSnapshot implements ProcessSnapshot { + private readonly byPid: Map + + constructor(private readonly rows: ProcessRow[]) { + this.byPid = new Map(rows.map(row => [row.pid, row])) + } + + tree(rootPid: number): ProcessIdentity[] { + return processTree(this.rows, rootPid) + } + + session(sessionId: number): ProcessIdentity[] { + return this.rows.flatMap(row => + row.session === sessionId ? [{ pid: row.pid, started: row.started }] : []) + } + + alive(identity: ProcessIdentity): boolean { + const row = this.byPid.get(identity.pid) + return row?.started === identity.started && !quiescent(row.state) + } +} + function processTree(entries: ProcessTreeEntry[], rootPid: number): ProcessIdentity[] { const byPid = new Map(entries.map(entry => [entry.pid, entry])) const root = byPid.get(rootPid) @@ -364,35 +438,34 @@ class LinuxProcessInspector extends PosixProcessInspector { return false } - processTree(rootPid: number): ProcessIdentity[] { - const entries = numericEntries(this.internals, '/proc').flatMap((pid) => { + snapshot(): ProcessSnapshot { + return new PosixProcessSnapshot(numericEntries(this.internals, '/proc').flatMap((pid) => { const stat = readLinuxStat(this.internals, pid) - return stat === undefined ? [] : [{ pid, parentPid: stat.parentPid, started: stat.started }] - }) - return processTree(entries, rootPid) - } - - processSession(sessionId: number): ProcessIdentity[] { - return numericEntries(this.internals, '/proc').flatMap((pid) => { - const stat = readLinuxStat(this.internals, pid) - return stat?.session === sessionId ? [{ pid, started: stat.started }] : [] - }) - } - - isAlive(identity: ProcessIdentity): boolean { - const stat = readLinuxStat(this.internals, identity.pid) - return stat?.started === identity.started && !/^[ZXx]$/.test(stat.state) + return stat === undefined ? [] : [{ + pid, + parentPid: stat.parentPid, + started: stat.started, + session: stat.session, + state: stat.state, + }] + })) } } -interface PsEntry extends ProcessTreeEntry {} - -function macProcessTable(internals: ProcessInspectorInternals): PsEntry[] { +// `ps` exposes neither the session id nor a state column in this format, so a +// macOS row can answer presence and parentage but never session membership. +function macProcessTable(internals: ProcessInspectorInternals): ProcessRow[] { return internals.exec('/bin/ps', ['-axo', 'pid=,ppid=,lstart=']).split('\n').flatMap((line) => { const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line) if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) return [] - return [{ pid: Number(match[1]), parentPid: Number(match[2]), started: match[3] }] + return [{ + pid: Number(match[1]), + parentPid: Number(match[2]), + started: match[3], + session: undefined, + state: undefined, + }] }) } @@ -410,16 +483,8 @@ class MacProcessInspector extends PosixProcessInspector { return false } - processTree(rootPid: number): ProcessIdentity[] { - return processTree(macProcessTable(this.internals), rootPid) - } - - processSession(_sessionId: number): ProcessIdentity[] { - return [] - } - - isAlive(identity: ProcessIdentity): boolean { - return macProcessTable(this.internals).some(entry => entry.pid === identity.pid && entry.started === identity.started) + snapshot(): ProcessSnapshot { + return new PosixProcessSnapshot(macProcessTable(this.internals)) } } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index 80782e24e7..eb6bf618c1 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -10,7 +10,7 @@ import type { SubprocessTerminalHandle, SubprocessTerminalSignal, } from '@deepseek-ai/dsh-subprocess' -import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' +import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts' function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) @@ -59,7 +59,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private readonly platform: NodeJS.Platform = process.platform, ) { this.pid = terminal.pid - this.rootIdentity = inspector.processTree(this.pid).find(member => member.pid === this.pid) + this.rootIdentity = inspector.snapshot().tree(this.pid).find(member => member.pid === this.pid) this.done = this.outcome.promise this.dataDisposable = terminal.onData((data) => { this.output.write(Buffer.from(data, 'utf8')) }) this.exitDisposable = terminal.onExit(({ exitCode, signal: exitSignal }) => { @@ -83,7 +83,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // Local inspection is synchronous; the seam returns a promise for remote transports. // oxlint-disable-next-line typescript/require-await -- Preserve promise rejection semantics at the async provider contract. async inspectForeground(): Promise { - this.descendants() + this.descendants(this.inspector.snapshot()) const processGroupId = this.inspector.foregroundPgid(this.pid) if (processGroupId === undefined) return undefined return { @@ -139,7 +139,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (this.exited) return if (this.rootIdentity !== undefined) { try { - this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL', this.inspector.snapshot()) } catch (_rootExitedDuringHostExit) { // Exact identity signalling contains both exit races and PID reuse. } @@ -152,42 +152,43 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } } - private survivors(members: ProcessIdentity[]): ProcessIdentity[] { - return members.filter(member => this.inspector.isAlive(member)) + private survivors(members: ProcessIdentity[], observed: ProcessSnapshot): ProcessIdentity[] { + return members.filter(member => observed.alive(member)) } - private descendants(): ProcessIdentity[] { + private descendants(observed: ProcessSnapshot): ProcessIdentity[] { // Adopt newly scanned members only while the numeric root pid provably // still carries the spawned shell's start identity: after the shell dies, // a recycled pid's tree and session must not donate an unrelated // process's children to this session's signalling. Already-adopted // members keep their own start identities, which every signal rechecks. - const tree = this.inspector.processTree(this.pid) + const tree = observed.tree(this.pid) const root = tree.find(member => member.pid === this.pid) const rootVerified = this.rootIdentity !== undefined && root !== undefined && root.started === this.rootIdentity.started this.trackedDescendants = this.survivors(this.unionMembers( this.trackedDescendants, - ...rootVerified ? [tree, this.inspector.processSession(this.pid)] : [], - ).filter(member => member.pid !== this.pid)) + ...rootVerified ? [tree, observed.session(this.pid)] : [], + ).filter(member => member.pid !== this.pid), observed) return this.trackedDescendants } private async waitForMembers(members: ProcessIdentity[]): Promise { const until = Date.now() + this.graceMs - let survivors = this.survivors(members) + let survivors = this.survivors(members, this.inspector.snapshot()) while (survivors.length > 0 && Date.now() < until) { await delay(Math.min(25, Math.max(1, until - Date.now()))) - survivors = this.survivors(members) + survivors = this.survivors(members, this.inspector.snapshot()) } return survivors } private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void { + const observed = this.inspector.snapshot() for (const member of members) { try { - this.inspector.signalProcess(member, signal) + this.inspector.signalProcess(member, signal, observed) } catch (_alreadyExitedDuringSignal) { // The exact process identity is rechecked; a same-tick exit is success. } @@ -197,7 +198,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private forceStopDescendants(): void { let members = this.trackedDescendants try { - members = this.descendants() + members = this.descendants(this.inspector.snapshot()) } catch (_processTableUnavailableDuringHostExit) { // Preserve already-captured identities when a final process-table scan fails. } @@ -219,13 +220,14 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async stopDescendants(): Promise { - const captured = this.descendants() + const captured = this.descendants(this.inspector.snapshot()) this.signalMembers(captured, 'SIGTERM') const capturedSurvivors = await this.waitForMembers(captured) - const members = this.unionMembers(capturedSurvivors, this.descendants()) + const members = this.unionMembers(capturedSurvivors, this.descendants(this.inspector.snapshot())) this.signalMembers(members, 'SIGKILL') const survivors = await this.waitForMembers(members) - return this.survivors(this.unionMembers(survivors, this.descendants())) + const observed = this.inspector.snapshot() + return this.survivors(this.unionMembers(survivors, this.descendants(observed)), observed) } private async stopShell(): Promise { @@ -262,9 +264,9 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // (the same console-list agent), so the tiers verify the shell's absence // through the inspector instead of waiting on `done` alone. const shellGone = (): boolean => - this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) + this.exited || (this.rootIdentity !== undefined && !this.inspector.snapshot().alive(this.rootIdentity)) if (!shellGone() && this.rootIdentity !== undefined) { - this.inspector.signalProcess(this.rootIdentity, 'SIGTERM') + this.inspector.signalProcess(this.rootIdentity, 'SIGTERM', this.inspector.snapshot()) await this.waitForWindowsShellExit() } if (!shellGone() && this.rootIdentity === undefined) { @@ -276,7 +278,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) } if (!shellGone() && this.rootIdentity !== undefined) { - this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL', this.inspector.snapshot()) await this.waitForWindowsShellExit() } if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) @@ -285,7 +287,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private async waitForWindowsShellExit(): Promise { const until = Date.now() + this.graceMs while (!this.exited && Date.now() < until) { - if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return + if (this.rootIdentity !== undefined && !this.inspector.snapshot().alive(this.rootIdentity)) return await delay(Math.min(25, Math.max(1, until - Date.now()))) } } @@ -315,7 +317,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (this.exited) return /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw; the identity re-check is a defensive fence for a future caller. */ - if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return + if (this.rootIdentity !== undefined && this.inspector.snapshot().alive(this.rootIdentity)) return this.exited = true this.output.end() this.outcome.resolve({ exitCode: null, signal: null }) diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 6889a3e65d..7b2ae5a23d 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -12,7 +12,7 @@ import { spawnSync } from 'node:child_process' import koffi from 'koffi' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' -import type { ProcessIdentity, ProcessInspector } from './process-inspector.ts' +import type { ProcessIdentity, ProcessInspector, ProcessSnapshot } from './process-inspector.ts' /** One Toolhelp32 process-table row. */ export interface ProcessEntry { @@ -97,25 +97,27 @@ export class WindowsProcessInspector implements ProcessInspector { return false } - processTree(rootPid: number): ProcessIdentity[] { - return windowsProcessTree(this.internals.snapshot(), rootPid, pid => this.internals.processState(pid)?.started) - } - - processSession(_sessionId: number): ProcessIdentity[] { - return [] - } - - isAlive(identity: ProcessIdentity): boolean { - const state = this.internals.processState(identity.pid) - return state?.active === true && state.started === identity.started + snapshot(): ProcessSnapshot { + const entries = this.internals.snapshot() + return { + tree: rootPid => windowsProcessTree(entries, rootPid, pid => this.internals.processState(pid)?.started), + // Windows has no POSIX sessions; the shell pid stands in as a pseudo group. + session: () => [], + alive: (identity) => { + // Wait state is a per-handle question, not a Toolhelp32 column, so + // liveness reads the live process object rather than `entries`. + const state = this.internals.processState(identity.pid) + return state?.active === true && state.started === identity.started + }, + } } signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { this.internals.taskkill(pgid, signal === 'SIGKILL') } - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { - if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void { + if (observed.alive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') } } /* jscpd:ignore-end */ diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 41e6b48bc8..589102ccf8 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -302,9 +302,7 @@ describe('LocalSubprocessRuntime', () => { const inspector = { foregroundPgid: () => undefined, isStdinWaiting: () => false, - processTree: () => [], - processSession: () => [], - isAlive: () => false, + snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }), signalGroup: () => {}, signalProcess: () => {}, } @@ -369,9 +367,11 @@ describe('LocalSubprocessRuntime', () => { ;(ctx.subprocess as InstanceType).terminalInspector = { foregroundPgid: () => 123, isStdinWaiting: () => false, - processTree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }], - processSession: () => [], - isAlive: identity => alive.has(identity.pid), + snapshot: () => ({ + tree: () => [{ pid: 123, started: 'shell' }, { pid: 124, started: 'child' }], + session: () => [], + alive: identity => alive.has(identity.pid), + }), signalGroup: () => {}, signalProcess: () => {}, } diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index cfea99f12a..c00c666e33 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -42,7 +42,7 @@ async function readTree(path: string): Promise { async function captureIdentities(inspector: ProcessInspector, state: TreeState): Promise { return vi.waitFor(() => { const expected = new Set([state.root, state.descendant]) - const identities = inspector.processTree(state.root).filter(identity => expected.has(identity.pid)) + const identities = inspector.snapshot().tree(state.root).filter(identity => expected.has(identity.pid)) if (identities.length !== expected.size) throw new Error('managed tree is not fully observable yet') return identities }, { interval: 10, timeout: scenarioTimeoutMs }) @@ -68,9 +68,10 @@ function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[] return } const inspector = createProcessInspector() + const observed = inspector.snapshot() for (const identity of identities) { try { - inspector.signalProcess(identity, 'SIGKILL') + inspector.signalProcess(identity, 'SIGKILL', observed) } catch (_alreadyGone) { // Exact start identity prevents PID-reuse cleanup from reaching another process. } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index ab34922576..ac5c95c13f 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -121,28 +121,31 @@ describe('Linux process inspector', () => { expect(inspector.foregroundPgid(10)).toBe(40) expect(inspector.foregroundPgid(11)).toBeUndefined() expect(inspector.foregroundPgid(99)).toBeUndefined() - expect(inspector.processTree(10)).toEqual([ + const observed = inspector.snapshot() + expect(observed.tree(10)).toEqual([ { pid: 13, started: '503' }, { pid: 12, started: '502' }, { pid: 10, started: '500' }, ]) - expect(inspector.processTree(99)).toEqual([]) - expect(inspector.processSession(30)).toEqual([ + expect(observed.tree(99)).toEqual([]) + expect(observed.session(30)).toEqual([ { pid: 10, started: '500' }, { pid: 11, started: '501' }, { pid: 12, started: '502' }, { pid: 13, started: '503' }, ]) - expect(inspector.processSession(99)).toEqual([]) - expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(true) - expect(inspector.isAlive({ pid: 10, started: 'old' })).toBe(false) + expect(observed.session(99)).toEqual([]) + expect(observed.alive({ pid: 10, started: '500' })).toBe(true) + expect(observed.alive({ pid: 10, started: 'old' })).toBe(false) inspector.signalGroup(40, 'SIGINT') - inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM') - inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL') + inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM', observed) + inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL', observed) expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z')) - expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false) - inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL') + // A zombie is present in the table but never signallable; a fresh capture sees the new state. + const afterExit = inspector.snapshot() + expect(afterExit.alive({ pid: 10, started: '500' })).toBe(false) + inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL', afterExit) expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) @@ -285,21 +288,22 @@ describe('macOS process inspector', () => { const inspector = createProcessInspector('darwin', 'arm64', fake.internals) expect(inspector.foregroundPgid(10)).toBe(55) expect(inspector.isStdinWaiting(55, 10)).toBe(false) - expect(inspector.processTree(10)).toEqual([ + const observed = inspector.snapshot() + expect(observed.tree(10)).toEqual([ { pid: 12, started: 'Mon Jul 21 10:00:02 2026' }, { pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, { pid: 10, started: 'Mon Jul 21 10:00:00 2026' }, ]) - expect(inspector.processTree(99)).toEqual([]) - expect(inspector.processSession(10)).toEqual([]) - expect(inspector.isAlive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true) + expect(observed.tree(99)).toEqual([]) + expect(observed.session(10)).toEqual([]) + expect(observed.alive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true) inspector.signalGroup(55, 'SIGTSTP') - inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL') - inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM') + inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL', observed) + inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM', observed) expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']]) fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n') - expect(inspector.processTree(10)).toEqual([ + expect(inspector.snapshot().tree(10)).toEqual([ { pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, { pid: 10, started: 'Mon Jul 21 10:00:00 2026' }, ]) diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 330660eda3..75beef6a34 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -1,9 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { IDisposable, IPty } from 'node-pty' import { LocalTerminalHandle } from '@deepseek-ai/dsh-subprocess-local/src/terminal.ts' +import { createProcessInspector } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { ProcessIdentity, ProcessInspector, + ProcessInspectorInternals, + ProcessSnapshot, } from '@deepseek-ai/dsh-subprocess-local/src/process-inspector.ts' import type { SubprocessTerminalSignal } from '@deepseek-ai/dsh-subprocess' @@ -69,18 +72,27 @@ class FakeInspector implements ProcessInspector { this.stdinChecks.push([pgid, shellPid]) return this.waiting } - processTree() { return this.root === undefined ? this.members : [this.root, ...this.members] } - processSession() { return this.sessionMembers } - isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } + /** Per-question table reads; tests replace one to stage a scan without rebuilding the fake. */ + readTree: () => ProcessIdentity[] = () => this.root === undefined ? this.members : [this.root, ...this.members] + readSession: () => ProcessIdentity[] = () => this.sessionMembers + readAlive: (identity: ProcessIdentity) => boolean = identity => this.alive.has(identity.pid) + + snapshot(): ProcessSnapshot { + return { + tree: () => this.readTree(), + session: () => this.readSession(), + alive: identity => this.readAlive(identity), + } + } signalGroup(pgid: number, signal: SubprocessTerminalSignal) { if (this.throwGroup) throw new Error('group failed') this.groups.push([pgid, signal]) } - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot) { // Mirrors the real inspectors' alive-gated signalling. if (!this.alive.has(identity.pid)) return if (this.throwProcess) throw new Error('process raced') - if (!this.isAlive(identity)) return + if (!observed.alive(identity)) return this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } @@ -104,8 +116,8 @@ describe('LocalTerminalHandle', () => { inspector.alive.add(pty.pid) inspector.alive.add(first.pid) const signalProcess = inspector.signalProcess.bind(inspector) - inspector.signalProcess = (identity, signal) => { - signalProcess(identity, signal) + inspector.signalProcess = (identity, signal, observed) => { + signalProcess(identity, signal, observed) if (identity.pid === pty.pid) { inspector.members = [first, late] inspector.alive.add(late.pid) @@ -135,7 +147,7 @@ describe('LocalTerminalHandle', () => { inspector.alive.add(captured.pid) const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) await handle.inspectForeground() - inspector.processTree = () => { throw new Error('process table unavailable') } + inspector.readTree = () => { throw new Error('process table unavailable') } inspector.throwProcess = true expect(() => { handle.terminateForHostExit() }).not.toThrow() @@ -166,7 +178,7 @@ describe('LocalTerminalHandle', () => { inspector.alive.add(pty.pid) const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) inspector.root = { pid: pty.pid, started: 'recycled' } - inspector.isAlive = identity => identity.started === 'recycled' + inspector.readAlive = identity => identity.started === 'recycled' handle.terminateForHostExit() @@ -258,7 +270,7 @@ describe('LocalTerminalHandle', () => { const pty = new FakePty() const inspector = new FakeInspector() const disowned = { pid: 124, started: 'disowned' } - inspector.processSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] + inspector.readSession = () => inspector.alive.has(disowned.pid) ? [disowned] : [] inspector.alive.add(124) const handle = makeHandle(pty, inspector, 20) @@ -318,7 +330,7 @@ describe('LocalTerminalHandle', () => { const inspector = new FakeInspector() const root = { pid: 123, started: 'shell' } let reads = 0 - inspector.processTree = () => { + inspector.readTree = () => { reads += 1 if (reads === 1) return [root] if (reads === 2) { @@ -385,7 +397,7 @@ describe('LocalTerminalHandle', () => { const root = { pid: 123, started: 'shell' } let reads = 0 inspector.alive.add(captured.pid) - inspector.processTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] } + inspector.readTree = () => { reads += 1; return reads === 1 ? [root] : reads === 2 ? [root, captured] : [] } inspector.signalProcess = (identity, signal) => { inspector.processes.push([identity.pid, signal]) if (signal === 'SIGKILL') inspector.alive.delete(identity.pid) @@ -514,3 +526,56 @@ describe('LocalTerminalHandle on Windows', () => { expect(inspector.processes).toEqual([]) }) }) + +describe('process-table read amplification', () => { + // The macOS inspector answers every question by forking `/bin/ps`, so a + // readiness poll that asks per descendant scales its blocking cost with the + // command's process tree. These pin the read count, not the wall time. + function darwinInternals(table: string): { internals: ProcessInspectorInternals; tableReads: string[] } { + const tableReads: string[] = [] + const unreachable = (): never => { throw new Error('darwin inspection uses exec and kill only') } + return { + tableReads, + internals: { + readFile: unreachable, + readDir: unreachable, + readLink: unreachable, + stat: unreachable, + open: unreachable, + read: unreachable, + close: unreachable, + exec(_file, args) { + if (args.includes('tpgid=')) return '456\n' + tableReads.push(args.join(' ')) + return table + }, + kill() {}, + }, + } + } + + /** A shell at pid 123 with `count` descendants chained beneath it. */ + function shellTable(count: number): string { + const rows = [' 123 1 Mon Jul 21 10:00:00 2026'] + for (let index = 0; index < count; index += 1) { + rows.push(` ${String(124 + index)} ${String(123 + index)} Mon Jul 21 10:00:${String(index + 1).padStart(2, '0')} 2026`) + } + return `${rows.join('\n')}\n` + } + + async function tableReadsForOnePoll(descendants: number): Promise { + const { internals, tableReads } = darwinInternals(shellTable(descendants)) + const inspector = createProcessInspector('darwin', 'arm64', internals) + const handle = new LocalTerminalHandle(new FakePty().asPty(), inspector, 10, 'darwin') + tableReads.length = 0 + const foreground = await handle.inspectForeground() + expect(foreground).toEqual({ processGroupId: 456, inputWaiting: false }) + return tableReads.length + } + + it('reads the macOS process table once per foreground inspection regardless of descendant count', async () => { + expect(await tableReadsForOnePoll(0)).toBe(1) + expect(await tableReadsForOnePoll(2)).toBe(1) + expect(await tableReadsForOnePoll(10)).toBe(1) + }) +}) diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index 5950fd9328..c2129923b4 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -66,7 +66,7 @@ describe('WindowsProcessInspector (injected internals)', () => { const inspector = new WindowsProcessInspector(fake.internals) expect(inspector.foregroundPgid(77)).toBe(77) expect(inspector.isStdinWaiting(77, 10)).toBe(false) - expect(inspector.processSession(77)).toEqual([]) + expect(inspector.snapshot().session(77)).toEqual([]) }) it('delegates tree walks and identity checks to the internals', () => { @@ -74,16 +74,16 @@ describe('WindowsProcessInspector (injected internals)', () => { fake.add({ pid: 10, parentPid: 0 }, 't10') fake.add({ pid: 11, parentPid: 10 }, 't11') const inspector = new WindowsProcessInspector(fake.internals) - expect(inspector.processTree(10)).toEqual([ + expect(inspector.snapshot().tree(10)).toEqual([ { pid: 11, started: 't11' }, { pid: 10, started: 't10' }, ]) - expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) - expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) - expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) + expect(inspector.snapshot().alive({ pid: 11, started: 't11' })).toBe(true) + expect(inspector.snapshot().alive({ pid: 11, started: 'stale' })).toBe(false) + expect(inspector.snapshot().alive({ pid: 99, started: 't99' })).toBe(false) fake.add({ pid: 12, parentPid: 10 }, 't12', false) - expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false) + expect(inspector.snapshot().alive({ pid: 12, started: 't12' })).toBe(false) }) it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { @@ -100,9 +100,9 @@ describe('WindowsProcessInspector (injected internals)', () => { fake.add({ pid: 10, parentPid: 0 }, 't10') fake.add({ pid: 11, parentPid: 10 }, 't11', false) const inspector = new WindowsProcessInspector(fake.internals) - inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') - inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL') - inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') + inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL', inspector.snapshot()) + inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL', inspector.snapshot()) + inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM', inspector.snapshot()) expect(fake.kills).toEqual([[10, true]]) }) @@ -130,19 +130,21 @@ const win32 = process.platform === 'win32' ? describe : describe.skip win32('WindowsProcessInspector over the real koffi bindings', () => { it('walks the live process table from the test runner itself', () => { const inspector = createWindowsProcessInspector() - const tree = inspector.processTree(process.pid) + const tree = inspector.snapshot().tree(process.pid) const self = tree.find(member => member.pid === process.pid) expect(self).toBeDefined() - expect(inspector.isAlive(self!)).toBe(true) + expect(inspector.snapshot().alive(self!)).toBe(true) expect(inspector.foregroundPgid(process.pid)).toBe(process.pid) }) it('reports unreadable identities for absent processes and no-ops tree signalling', () => { const inspector = createWindowsProcessInspector() - expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) + expect(inspector.snapshot().alive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow() expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow() expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow() - expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow() + expect(() => { + inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL', inspector.snapshot()) + }).not.toThrow() }) }) diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index 897690cbcb..02ce95db02 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -27,9 +27,13 @@ class FakeInspector implements ProcessInspector { foregroundPgid() { return this.pgid } isStdinWaiting() { return this.waiting } - processTree() { return this.members } - processSession() { return [] } - isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } + snapshot() { + return { + tree: () => this.members, + session: () => [], + alive: (identity: ProcessIdentity) => this.alive.has(identity.pid), + } + } signalGroup(pgid: number, signal: TerminalSignal) { if (this.throwGroup) throw new Error('group failed') this.groups.push([pgid, signal]) From c873fc9d2ed2237ad3fd4e12ad3deaa21b46170a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:08:17 +0800 Subject: [PATCH 052/130] fix(client): stop rebuilding the turn rail on every chat render TurnNavigator was an unmemoized component rendering one div and one button per loaded Turn, so every ChatView render rebuilt the whole rail: 143 button rebuilds per commit in a 300-Turn session against 8.4 in a 4-Turn one, while a streaming answer commits dozens of times. memo alone would not have helped, because navigateToTurn was rebuilt on every render and broke prop identity, so it moves into useCallback. --- .../ui-chat/src/client/chat/ChatView.tsx | 5 ++-- .../ui-chat/src/client/chat/TurnNavigator.tsx | 16 +++++++++-- .../ui-chat/tests/chat-view.client.spec.tsx | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index ade617c95e..ecce592e67 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -498,7 +498,8 @@ export function ChatView({ loadOlder() } - const navigateToTurn = (item: TurnNavigationItem): void => { + // Identity feeds the memoized rail; a fresh closure per render would defeat it. + const navigateToTurn = useCallback((item: TurnNavigationItem): void => { const local = listRef.current if (local === null) return const row = anchorElement(local, item.anchorKey) @@ -519,7 +520,7 @@ export function ChatView({ const position = isAtBottom ? null : scrollPosition(local, el) if (isAtBottom) chatScroll.save(null) else if (position !== null) chatScroll.save(position) - } + }, [loadingOlder, chatScroll]) return (
    diff --git a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx index b2533e533c..55818f85cd 100644 --- a/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnNavigator.tsx @@ -1,5 +1,5 @@ import { - useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, + memo, useId, useState, type CSSProperties, type MouseEvent, type PointerEvent, } from 'react' import type { ChatViewSlotProps } from '../contract/slots.ts' import type { TurnNavigationItem } from '../contract/snapshot.ts' @@ -53,8 +53,7 @@ function itemAtPointer( return items[Math.round(ratio * (items.length - 1))] } -/** Compact rail of the currently loaded Turns with hover and focus previews. */ -export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { +function TurnNavigatorRail({ items, activeTurn, onNavigate, t }: TurnNavigatorProps) { const [previewTurn, setPreviewTurn] = useState(null) const previewId = useId() if (items.length < 2) return null @@ -116,3 +115,14 @@ export function TurnNavigator({ items, activeTurn, onNavigate, t }: TurnNavigato
    ) } + +/** + * Compact rail of the currently loaded Turns with hover and focus previews. + * + * Memoized because it renders two host elements per loaded Turn while the + * enclosing view re-renders on every streaming delta: without the guard a long + * session rebuilds hundreds of marks per commit for a rail that only changes + * when a Turn is added, removed, or becomes active. Its props must therefore + * stay referentially stable across those commits. + */ +export const TurnNavigator = memo(TurnNavigatorRail) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index ec6af626ee..7de458c73c 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -412,6 +412,34 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { + const snapshot = chatSnapshotFixture({ + nodes: [ + userInTurn(1, 'first prompt', 1), + assistant(2, 'first response', 1), + userInTurn(4, 'second prompt', 2), + assistant(5, 'second response', 2), + ], + turnEnds: new Map([[1, 3], [2, 6]]), + }) + const h = makeHarness({}, {}, snapshot) + // The rail asks for its own accessible name once per render, so counting + // that key counts renders without reaching into the component. + let railRenders = 0 + const translate = h.props.t + const counting = ((key: string, vars?: Record) => { + if (key === 'chat.turnNavigation.label') railRenders += 1 + return (translate as (k: string, v?: Record) => string)(key, vars) + }) as ChatViewSlotProps['t'] + render() + const afterMount = railRenders + expect(afterMount).toBeGreaterThan(0) + + act(() => { h.setSelection({ turnSeq: 3, callId: 'a', toolName: 'bash' }) }) + + expect(railRenders).toBe(afterMount) + }) + it('projects loaded turns into prompt and response navigation previews', () => { const snapshot = chatSnapshotFixture({ nodes: [ From a7b054b8f667e6f85ce0e2d197c85a285a5c5cd0 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 27 Aug 2026 13:09:08 +0800 Subject: [PATCH 053/130] docs: refresh module dependency graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 8866f5a1f1..5d08c21d3e 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 93170bd4d76d768d1cf5f1bc5efdf94d1ce2453a -module-graph.zh.md: ac7f7e70d2364469ce5de5bf0734457e8e4dfb3e +module-graph.md: 28885c4bde3215075f213135ad64db8feadb4a9a +module-graph.zh.md: 68d22f46c645dd62426f893bec4a0b5399d5ef6e diff --git a/docs/module-graph.md b/docs/module-graph.md index 93170bd4d7..28885c4bde 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1343,6 +1343,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1900,7 +1901,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ac7f7e70d2..68d22f46c6 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1345,6 +1345,7 @@ flowchart TD pkg_client_ui_settings_plugin_inventory --> pkg_client_ui_settings pkg_client_ui_settings_plugin_inventory --> pkg_invariants pkg_client_ui_settings_plugins --> pkg_api_remotes + pkg_client_ui_settings_plugins --> pkg_client_connection pkg_client_ui_settings_plugins --> pkg_client_locale pkg_client_ui_settings_plugins --> pkg_client_ui_renderer pkg_client_ui_settings_plugins --> pkg_client_ui_settings @@ -1902,7 +1903,7 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`cordis-client-runner`](../packages/extensions/cordis-client-runner) | `extensions` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/runtime-diagnostics/invariants) | From 49753b33fa39051e65e12aafe56939d66ebcedaf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:21:22 +0800 Subject: [PATCH 054/130] fix(web): keep question card props data-only --- ...29-ask-question-web-presentation.i18n.yaml | 4 +- ...026-07-29-ask-question-web-presentation.md | 2 +- ...-07-29-ask-question-web-presentation.zh.md | 2 +- apps/web/tests/question-composer.e2e.ts | 8 ++- .../AskQuestionCard.module.css} | 0 .../tool/components/AskQuestionCard.tsx | 40 +++++++++++++++ .../src/client/tool/components/ToolRow.tsx | 16 +++--- .../tool/models/ask-question-card-model.ts | 25 +++++++++ .../tool/toolviews/ask-question-row.tsx | 51 +++---------------- 9 files changed, 92 insertions(+), 56 deletions(-) rename packages/client/ui-tool/src/client/tool/{toolviews/ask-question-row.module.css => components/AskQuestionCard.module.css} (100%) create mode 100644 packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx create mode 100644 packages/client/ui-tool/src/client/tool/models/ask-question-card-model.ts diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml index 5d1e095629..b8b98a6ecb 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.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-29-ask-question-web-presentation.md -2026-07-29-ask-question-web-presentation.md: c51c6458fc01d4d99ec9784566439cfca31c43ad -2026-07-29-ask-question-web-presentation.zh.md: ab027b33ba2874547b986251665d611f5b984762 +2026-07-29-ask-question-web-presentation.md: 280671570ca015457ab3c17241cea3e973b88fd2 +2026-07-29-ask-question-web-presentation.zh.md: 9ad687d655de19ea913ddc5255fb6426ef0fbb77 diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md index c51c6458fc..280671570c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md @@ -42,7 +42,7 @@ Two adjacent fixes ride along. All generic toolview leading icons (and the hover `ask_user_question` and `todo_write` now demonstrate the intended toolview pattern: compose `ToolRow`, summarize from call args or result JSON with shape-checked fallbacks, and register through the keyed slot. The bespoke `todo-row.module.css` is gone. -The expanded transcript adds a structured-body path to the shared `ToolRow`; other tool views retain their existing generic or specialized cards. The question row reads only persisted call and result fields and does not add a Host presentation field. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. +The expanded transcript adds a typed, plain-data question-card model to the shared `ToolRow`; other tool views retain their existing generic or specialized cards. The question row reads only persisted call and result fields and does not add a Host presentation field. The approval composer takeover shipped ([web permission and approval](2026-07-23-web-permission-and-approval.md), height-capped per the [approval-panel note](../bug-fix/2026-07-30-approval-panel-command-cap.md)), and `PendingCard` no longer exists. `ui-user-questions` gains a `dsh-client-locale` dependency and an inject face where it previously had none; its contract (`QuestionComposerInjected`) lives with the consumer in `contract/slots.ts`. diff --git a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md index ab027b33ba..9ad687d655 100644 --- a/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md @@ -42,7 +42,7 @@ Web GUI 已经可以通过 `QuestionComposer` 的输入区接管收集回答, `ask_user_question` 与 `todo_write` 现在共同示范预期的 toolview 模式:复用 `ToolRow`、从调用参数或结果 JSON 做带形状校验回退的摘要、通过带 key 的 slot 注册。专用的 `todo-row.module.css` 已删除。 -展开问答记录为共享 `ToolRow` 增加一条结构化内容路径;其他工具视图保留原有的通用或专用卡片。问题行只读取已持久化的调用与结果字段,不增加 Host 呈现字段。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 +展开问答记录为共享 `ToolRow` 增加类型化的纯数据问题卡片模型;其他工具视图保留原有的通用或专用卡片。问题行只读取已持久化的调用与结果字段,不增加 Host 呈现字段。审批输入区接管已交付([Web 权限与审批](2026-07-23-web-permission-and-approval.zh.md),并按[审批面板 Agent Note](../bug-fix/2026-07-30-approval-panel-command-cap.zh.md)施加高度上限),`PendingCard` 已不复存在。 `ui-user-questions` 新增 `dsh-client-locale` 依赖和此前没有的 inject face;其约定(`QuestionComposerInjected`)与消费方一起放在 `contract/slots.ts`。 diff --git a/apps/web/tests/question-composer.e2e.ts b/apps/web/tests/question-composer.e2e.ts index 36aadf2f20..d0956666b0 100644 --- a/apps/web/tests/question-composer.e2e.ts +++ b/apps/web/tests/question-composer.e2e.ts @@ -75,6 +75,8 @@ function cancelledFixture(fixture: string): string { const event: unknown = JSON.parse(line) if (!isRecord(event)) throw new Error('question fixture event is invalid') if (event.type === 'session') { + // Keep the derived session's relative-time header stable as the source + // fixture ages. event.createdAt = Date.now() lines.push(JSON.stringify(event)) continue @@ -100,7 +102,11 @@ function cancelledFixture(fixture: string): string { text: 'Error: the user cancelled ask_user_question', }] message.content[0].isError = true - data.error = { name: 'UserQuestionError', code: 'ASK_CANCELLED' } + data.error = { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + } replaced = true lines.push(JSON.stringify(event)) } diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css similarity index 100% rename from packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.module.css rename to packages/client/ui-tool/src/client/tool/components/AskQuestionCard.module.css diff --git a/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx new file mode 100644 index 0000000000..d704aea501 --- /dev/null +++ b/packages/client/ui-tool/src/client/tool/components/AskQuestionCard.tsx @@ -0,0 +1,40 @@ +/** Ask-user transcript rendering from validated plain card data. @module */ + +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' +import css from './AskQuestionCard.module.css' + +/** + * Render a validated ask-user transcript from plain card data. + * @param props - Localized transcript card data. + * @returns the readable answered or unanswered question list. + */ +export function AskQuestionCard({ card }: { card: AskQuestionCardModel }) { + if (card.kind === 'unanswered') { + return ( +
    +

    {card.verdict}

    +
      + {card.questions.map(question => ( +
    • {question.question}
    • + ))} +
    +
    + ) + } + return ( +
    + {card.questions.map(question => ( +
    +
    {question.question}
    +
    + {question.answers.length === 0 + ? {card.skippedLabel} + : question.answers.map((answer, index) => ( + {answer} + ))} +
    +
    + ))} +
    + ) +} diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index d38bc55c9c..ccf211e468 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -13,8 +13,10 @@ import { import { diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels, } from '../models/primitive-labels.ts' +import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../models/tool-call-model.ts' import type { WebCardModelProps } from '../models/web-card-model.ts' +import { AskQuestionCard } from './AskQuestionCard.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -37,8 +39,8 @@ export interface ToolRowProps { body: string | null /** Flattened result text for the expanded Output section; null/absent = no output section. */ output?: string | null | undefined - /** Tool-owned structured body that replaces the generic input/output sections. */ - structuredBody?: ReactNode | null | undefined + /** Ask-user transcript card; card fields are mutually exclusive and replace text sections. */ + askQuestion?: AskQuestionCardModel | null | undefined /** Error first line shown as the collapsed summary on an error row; null/absent = keep `summary`. */ errorSummary?: string | null | undefined /** Terminal card; card fields are mutually exclusive and replace text sections. */ @@ -93,7 +95,7 @@ export function ToolRow({ summarySuffix, body, output, - structuredBody, + askQuestion, errorSummary, terminal, diff, @@ -118,9 +120,9 @@ export function ToolRow({ const readBody = read ?? null const searchBody = search ?? null const webBody = web ?? null - const ownedBody = structuredBody ?? null + const askQuestionBody = askQuestion ?? null const outputText = output ?? null - const card = ownedBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -187,8 +189,8 @@ export function ToolRow({ )} >
    - {ownedBody !== null - ? ownedBody + {askQuestionBody !== null + ? : terminalBody !== null ? ( { return typeof value === 'object' && value !== null && !Array.isArray(value) } @@ -125,42 +121,7 @@ function answeredPresentation( } } -function QuestionTranscriptCard({ transcript, t }: { - transcript: QuestionTranscript - t: AskQuestionRowProps['t'] -}) { - if (transcript.kind === 'unanswered') { - return ( -
    -

    {transcript.verdict}

    -
      - {transcript.questions.map(question => ( -
    • {question.question}
    • - ))} -
    -
    - ) - } - return ( -
    - {transcript.questions.map(question => ( -
    -
    {question.question}
    -
    - {question.answers.length === 0 - ? {t('ask.skipped')} - : question.answers.map((answer, index) => ( - {answer} - ))} -
    -
    - ))} -
    - ) -} - -/** Answered-count summary from the result JSON (a skipped question has - * empty `selected` and no `custom`); null when answer fields are invalid. */ +/** Best-effort answered-count summary when strict transcript pairing fails. */ function answeredSummary(text: string, t: AskQuestionRowProps['t']): string | null { const parsed = parseJson(text) if (!isRecord(parsed)) return null @@ -187,7 +148,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr const argsRaw = ('kind' in block ? block.call?.argsRaw : block.argsRaw) ?? '' let summary = model.summary let state = model.state - let transcript: QuestionTranscript | null = null + let transcript: AskQuestionCardModel | null = null if (code === 'ASK_CANCELLED') { summary = t('ask.cancelled') state = 'ok' @@ -207,9 +168,11 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr } else if ('kind' in block && model.state === 'ok') { const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('') const presentation = answeredPresentation(argsRaw, text, t) + // Full transcripts require stable ids and valid visible fields; retain the + // legacy best-effort count when only strict pairing is unsafe. summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary if (presentation?.questions !== null && presentation?.questions !== undefined) { - transcript = { kind: 'answered', questions: presentation.questions } + transcript = { kind: 'answered', questions: presentation.questions, skippedLabel: t('ask.skipped') } } } return ( @@ -222,7 +185,7 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr summary={summary} body={transcript === null ? model.body : null} output={transcript === null ? model.output : null} - structuredBody={transcript === null ? null : } + askQuestion={transcript} state={state} inspect={inspect} /> From 2722c202adc3a26a9dafb3d3d190ee89b1063d42 Mon Sep 17 00:00:00 2001 From: Dudu-0223 Date: Thu, 27 Aug 2026 13:25:51 +0800 Subject: [PATCH 055/130] fix(subagent): tolerate policy-only preset states --- .../subagent/tool-subagent/src/invariant.ts | 26 +++++++++------- .../tests/model-selection-settings.spec.ts | 31 +++++++++++++++++-- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index 9207e01e4f..0c6751e506 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -15,20 +15,22 @@ export const name = 'tool-subagent-invariant' /** Service required before the companion can reserve package ownership. */ export const inject = ['invariants'] -/** Assert that a durable opt-in is represented by both model-facing definitions. */ +/** Assert that model-selectable definitions are complete and reconstructable. */ const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { ctx.on('agent/pre-step', async ({ agent }, next) => { - if (subagentModelSelectionPolicy(agent.session) !== undefined) { - const schemas = ctx.tools.schemas(agent) - const selectable = schemas.some((schema) => { - const properties = (schema.parameters as { properties?: Record }).properties - return properties?.['provider'] !== undefined - && properties['model'] !== undefined - && properties['reasoning_effort'] !== undefined - }) - if (!selectable || !schemas.some(schema => schema.name === 'list_subagent_models')) { - fail('a subagent/model-selection-policy session must expose route fields and list_subagent_models') - } + const schemas = ctx.tools.schemas(agent) + const selectable = schemas.some((schema) => { + const properties = (schema.parameters as { properties?: Record }).properties + return properties?.['provider'] !== undefined + && properties['model'] !== undefined + && properties['reasoning_effort'] !== undefined + }) + const discoverable = schemas.some(schema => schema.name === 'list_subagent_models') + if ( + (selectable || discoverable) + && (subagentModelSelectionPolicy(agent.session) === undefined || !selectable || !discoverable) + ) { + fail('model-selectable subagent definitions require a durable policy, route fields, and list_subagent_models') } return next() }, { global: true }) diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index ac1a103d11..53cbfac798 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -189,6 +189,8 @@ describe('SubagentModelSelectionConfig', () => { it('installs per-Agent definitions for a shared preset scope', async () => { const ctx = await boot() + await ctx.plugin(InvariantRegistry, { enabled: true }) + await ctx.plugin(ToolInvariant) const preset = createScope(ctx, { preset: 'standard' }) const other = createScope(ctx, { preset: 'minimal' }) await preset.ctx.plugin(tool, { @@ -219,9 +221,22 @@ describe('SubagentModelSelectionConfig', () => { enabledBinding!.rebind(scopeOf(other.ctx)!) ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(false) }) + const next = () => Promise.resolve({ kind: 'enter' as const, messages: [] }) + const payload = { + agent: enabled.agent, + messages: [], + turn: 1, + step: 1, + signal: new AbortController().signal, + } + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) + .resolves.toEqual({ kind: 'enter', messages: [] }) + enabledBinding!.rebind(scopeOf(preset.ctx)!) ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') await vi.waitFor(() => { expect(selectable(ctx, enabled.agent)).toBe(true) }) + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) + .resolves.toEqual({ kind: 'enter', messages: [] }) await enabled.dispose() ctx.emit(scopeTarget({}, scopeOf(preset.ctx)), 'tools/change') @@ -322,7 +337,7 @@ describe('SubagentModelSelectionConfig', () => { await withoutAgent.fiber.dispose() }) - it('checks the durable decision against the published tool definitions', async () => { + it('checks model-selectable definitions without rejecting a policy-only preset', async () => { const ctx = await boot() await ctx.plugin(InvariantRegistry, { enabled: true }) await ctx.plugin(ToolInvariant) @@ -341,7 +356,7 @@ describe('SubagentModelSelectionConfig', () => { disabled.session.append('subagent/model-selection-policy', { allowedModels: ALLOWED_MODELS }) await expect(ctx.waterfall(ctx as never, 'agent/pre-step', payload, next)) - .rejects.toThrow('must expose route fields and list_subagent_models') + .resolves.toEqual({ kind: 'enter', messages: [] }) await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: true, @@ -350,6 +365,18 @@ describe('SubagentModelSelectionConfig', () => { const enabled = await createAgent(ctx, 'invariant-enabled') await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) .resolves.toEqual({ kind: 'enter', messages: [] }) + + const enabledSchemas = ctx.tools.schemas(enabled) + const schemas = vi.spyOn(ctx.tools, 'schemas') + schemas.mockReturnValue(enabledSchemas.filter(schema => schema.name !== 'list_subagent_models')) + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: enabled }, next)) + .rejects.toThrow('require a durable policy, route fields, and list_subagent_models') + + schemas.mockReturnValue(enabledSchemas) + await ctx.settings.update(SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE, { enabled: false }) + const withoutPolicy = await createAgent(ctx, 'invariant-without-policy') + await expect(ctx.waterfall(ctx as never, 'agent/pre-step', { ...payload, agent: withoutPolicy }, next)) + .rejects.toThrow('require a durable policy, route fields, and list_subagent_models') await ctx.fiber.dispose() }) }) From af562d3649a8925ce048aa6cb9fc5ebec3543919 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:03:59 +0800 Subject: [PATCH 056/130] fix(api-gateway): keep idle websocket alive --- ...sion-history-and-event-transport.i18n.yaml | 4 +-- ...-18-session-history-and-event-transport.md | 10 ++++-- ...-session-history-and-event-transport.zh.md | 10 ++++-- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 17 +++++++++- docs/config-catalog.zh.md | 17 +++++++++- packages/api/gateway/README.i18n.yaml | 4 +-- packages/api/gateway/README.md | 3 +- packages/api/gateway/README.zh.md | 3 +- packages/api/gateway/package.json | 2 ++ packages/api/gateway/src/index.ts | 22 ++++++++++++- packages/api/gateway/src/stream-server.ts | 17 ++++++++++ .../gateway/tests/gateway-stream.host.spec.ts | 31 +++++++++++++++++-- .../gateway/tests/stream-server.host.spec.ts | 29 +++++++++++++++-- packages/api/gateway/tsconfig.host.json | 6 ++++ pnpm-lock.yaml | 6 ++++ 16 files changed, 166 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 00652d3b09..2cd0ed343b 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 808565ff7df60b8aa6aa3f18820c1139b8bf5362 -2026-08-18-session-history-and-event-transport.zh.md: 8bd00def4531afa9cdf77ae7f689f2e77908545e +2026-08-18-session-history-and-event-transport.md: 5f4aba19d147eae3f49fcefc9a8006d0f557dc6c +2026-08-18-session-history-and-event-transport.zh.md: dcf7e7ffc5ad325756b1fb37dd5ad60d2f0b3147 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 808565ff7d..5f4aba19d1 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -62,6 +62,8 @@ API Proxy owns neither the Session or Workspace Remote namespace nor the Host do The browser's Client Remote plugin starts `RemoteStreamMuxClient` idempotently on activation and connects to `/api/remote.mux` immediately. The physical WebSocket remains resident even when there is no business logical stream. +The Host sends one RFC 6455 Ping control frame to every open mux socket at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default). The browser replies with Pong at the protocol layer; neither control frame enters the Remote stream JSON union or changes Connection generation state. The Host imposes no Pong deadline, so half-open detection remains with TCP and network intermediaries. + After an initial connection failure or the loss of a connected socket, the mux rebuilds the physical connection with capped jittered backoff. Logical streams not yet opened share that reconnect loop; streams already open end their current physical generation with `RemoteStreamCarrierError`. In-process `connection.rpc.open` uses the same logical endpoint semantics while bypassing the browser WebSocket mux. @@ -76,7 +78,7 @@ Unexpected normal completion of `$events`, a Host error, a malformed opening fra Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. -Plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. +Host plugin disposal stops the heartbeat timer, terminates mux sockets, and waits for active iterators. Client plugin disposal stops backoff, cancels candidate and active sockets, ends logical streams, and awaits quiescence of background loops and consumers. ### General Remote stream model @@ -320,13 +322,15 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re **Use an independent physical WebSocket or duplex stream for Remote Event.** Gateway mux already provides authenticated upgrade, multiplexing, cancellation, error mapping, and reconnect. Downlink `$events` plus HTTP `$events/result` expresses request/response without a third connection. +**Send application-level JSON heartbeat frames.** This would expand the strict Remote stream message union and require browser handling for traffic with no business meaning. WebSocket Ping/Pong provides carrier activity without changing logical-stream semantics. + **Retain API Proxy's Host mux.** This keeps the handwritten union, schema, response envelope, and second stream lifecycle, and prevents Session and Workspace Controllers from owning their data protocols independently. **Update Session list time from aggregate `session/event`.** List correctness would depend on which Sessions a browser consumes and would mistake arbitrary plugin events for user activity. The durable `lastPromptAt` projection expresses the ordering fact directly. ## Verification -Gateway mux tests pin connection without logical streams, idle residency, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. +Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. @@ -364,6 +368,8 @@ Durable logs repair a missing suffix by sequence number and page; Session contro Gateway owns only transport, generation, pending waterfalls, and strict wire validation, not Session or Workspace business fields. A domain Controller supplies only openers, cursor rules, baseline reducers, and error presentation. +Each resident browser connection adds one empty Ping/Pong exchange per configured interval. Deployments can shorten the interval for stricter idle timeouts without changing the Remote stream protocol or browser code. + Session and Workspace Host APIs, stream adapters, and Client data models each have an explicit owner. API Proxy is no longer their intermediary. The general stream objects add three explicit layers while deleting the retry, cancellation, generation, baseline, and gap-repair shells previously duplicated by each Controller. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 8bd00def45..dcf7e7ffc5 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -62,6 +62,8 @@ API Proxy 不拥有 Session 或 Workspace Remote namespace,也不拥有 Host 浏览器的 Client Remote 插件激活时幂等启动 `RemoteStreamMuxClient`,并立即连接 `/api/remote.mux`。没有业务 logical stream 时物理 WebSocket 仍保持常驻。 +Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每条已打开的 mux socket 发送一个 RFC 6455 Ping 控制帧;浏览器在协议层回复 Pong。两种控制帧都不进入 Remote stream JSON union,也不改变 Connection generation 状态。Host 不设置 Pong deadline,因此半开检测仍由 TCP 与网络中间层承担。 + 首次建连失败或已连接 socket 丢失后,mux 使用有上限的抖动退避重建物理连接。尚未打开的 logical stream 共享该重连循环;已经打开的 stream 以 `RemoteStreamCarrierError` 结束当前物理 generation。 进程内 `connection.rpc.open` 使用同一 logical endpoint 语义,但绕过浏览器 WebSocket mux。 @@ -76,7 +78,7 @@ Host event source 在返回首帧前同步安装增量 listener。Gateway 随后 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 -插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 静默退出。 +Host 插件销毁会停止心跳定时器、终止 mux socket,并等待活跃 iterator 完成。Client 插件销毁会停止退避,取消候选与活动 socket,终止 logical stream,并等待后台循环和 consumer 完全停稳。 ### 通用 Remote stream 模型 @@ -320,13 +322,15 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace **给 Remote Event 使用独立物理 WebSocket 或 duplex stream。** Gateway mux 已提供认证升级、复用、取消、错误映射和重连;下行 `$events` 加上 HTTP `$events/result` 足以表达 request/response,不需要第三条连接。 +**发送应用层 JSON 心跳帧。** 这会扩展严格的 Remote stream message union,并要求浏览器处理没有业务含义的流量。WebSocket Ping/Pong 无需改变 logical stream 语义即可保持 carrier 活跃。 + **继续保留 API Proxy 的 Host mux。** 这会保留手写 union、schema、响应 envelope 和第二套 stream 生命周期,并使 Session 与 Workspace Controller 不能独立拥有自己的数据协议。 **从聚合 `session/event` 更新 Session 列表时间。** 列表正确性会依赖浏览器正在消费哪些 Session,并把任意插件事件误判为用户活跃;持久 `lastPromptAt` 投影直接表达排序事实。 ## 验证 -Gateway mux 测试固定无 logical stream 时建连、空闲常驻、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 +Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 @@ -364,6 +368,8 @@ Remote Event Client 测试固定实例私有 key、Cordis 注册顺序、Agent C Gateway 只拥有 transport、generation、pending waterfall 和严格 wire 校验,不拥有 Session 或 Workspace 业务字段。领域 Controller 只提供 opener、cursor 规则、baseline reducer 和错误呈现。 +每条常驻浏览器连接会按配置间隔增加一次空载荷 Ping/Pong 交换。面对更严格的空闲超时,部署方可缩短间隔,而无需改变 Remote stream 协议或浏览器代码。 + Session 与 Workspace 的 Host API、stream adapter 和 Client 数据模型各有明确 owner;API Proxy 不再是它们之间的中介。 通用 stream 对象增加了三个明确层级,但删除了每个 Controller 各自复制的 retry、cancel、generation、baseline 和 gap-repair 外壳。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2407817a57..750dab17ed 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 57b7f120ff09459e02f998806f6335f2b45d1b1b -config-catalog.zh.md: f0911b9d004d885868758351e16e3783b660eb18 +config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289 +config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 57b7f120ff..ab16221ff6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -274,6 +274,22 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +Requires: `typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -3314,7 +3330,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — requires `typert` ([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index f0911b9d00..8c9d956ad3 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -276,6 +276,22 @@ export interface Config { 来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts) + + +## `@deepseek-ai/dsh-api-gateway` + +需要:`typert` + +```ts config-catalog +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} +``` + +来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -3316,7 +3332,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) -- `@deepseek-ai/dsh-api-gateway` — 需要 `typert`([`packages/api/gateway/src/index.ts`](../packages/api/gateway/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) - `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 9bc399b963..8353039833 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac -README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca +README.md: 504ff95494d8c374426c18d0a77fb238457561e3 +README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 2e0cb32e4d..504ff95494 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -32,7 +32,7 @@ The Host entry registers a trusted-host interceptor on Connection's shared `/api A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. -A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. @@ -68,6 +68,7 @@ No direct effect; invoked business Services own any model-visible result. - `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. - Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. +- WebSocket heartbeats keep idle intermediaries active but do not require a timely Pong or terminate an unresponsive peer. Half-open carriers remain subject to TCP or intermediary failure detection before the Client reconnects. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index f67e13f6b1..e556b4981c 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -32,7 +32,7 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 -流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 @@ -68,6 +68,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source - `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 - 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 +- WebSocket 心跳用于保持空闲中间层活跃,但不会要求及时收到 Pong,也不会主动终止无响应对端。半开 carrier 仍需等待 TCP 或中间层检测失败后,Client 才会重连。 diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index e74d19946f..13c7e402ae 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -56,7 +56,9 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "peerDependencies": { diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 86a71d834a..ec4eb1eaef 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -9,6 +9,8 @@ import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import z from '@deepseek-ai/schemastery' import { remoteMethods, TypertLookupFailure, @@ -106,6 +108,17 @@ interface PendingRemoteEvent { type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal +const DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000 + +/** Gateway transport configuration. */ +export interface Config { + /** WebSocket Ping interval from 1 through 2,147,483,647 milliseconds. @default 30000 */ + readonly websocketHeartbeatIntervalMs?: number +} + +interface ResolvedConfig extends Config { + readonly websocketHeartbeatIntervalMs: number +} /** Dispatch failure produced outside the invoked business method. */ export class TypertGatewayError extends Error { @@ -156,6 +169,10 @@ class RemoteInvocationCancelled extends Error { */ export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + static Config: z = z.object({ + websocketHeartbeatIntervalMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS) + .default(DEFAULT_WEBSOCKET_HEARTBEAT_INTERVAL_MS), + }) /** Carrier adapter shared by the WebSocket mux and local Host transports. */ readonly wireStream: TypertGatewayWireStream = { @@ -171,9 +188,11 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the Gateway against the active Typert registry. * @param ctx - owning Host Context with Typert registry access. + * @param config - validated Gateway transport configuration. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: Config) { super(ctx, 'typertGateway') + const resolved = config as ResolvedConfig ctx.on('internal/service', () => { this.srcClaims = undefined }) @@ -188,6 +207,7 @@ export class TypertGatewayService extends Service implements TypertGateway { const mux = new RemoteStreamMuxServer( (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), this.wireStream.failure, + resolved.websocketHeartbeatIntervalMs, ) webCtx.effect(() => { const route: WebUpgradeRoute = { diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts index 04b0df0427..28a3589542 100644 --- a/packages/api/gateway/src/stream-server.ts +++ b/packages/api/gateway/src/stream-server.ts @@ -23,14 +23,17 @@ export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure export class RemoteStreamMuxServer { private readonly server = new WebSocketServer({ noServer: true }) private readonly connections = new Set>() + private heartbeatTimer: NodeJS.Timeout | undefined /** * @param open - Gateway stream dispatcher. * @param failure - Gateway error-to-wire mapper. + * @param heartbeatIntervalMs - interval between WebSocket Ping control frames. */ constructor( private readonly open: RemoteStreamOpener, private readonly failure: RemoteStreamFailureMapper, + private readonly heartbeatIntervalMs: number, ) {} /** @@ -41,6 +44,7 @@ export class RemoteStreamMuxServer { */ handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { this.server.handleUpgrade(req, socket, head, (websocket) => { + this.startHeartbeat() const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) const done = connection.run() this.connections.add(done) @@ -50,6 +54,8 @@ export class RemoteStreamMuxServer { /** Terminate all sockets and wait until every iterator has returned. */ async close(): Promise { + clearInterval(this.heartbeatTimer) + this.heartbeatTimer = undefined for (const socket of this.server.clients) socket.terminate() const closed = Promise.withResolvers() this.server.close((error) => { @@ -59,6 +65,17 @@ export class RemoteStreamMuxServer { await closed.promise await Promise.all(this.connections) } + + /** Start one `unref()` timer after the first upgrade; it spans empty-client periods until close(). */ + private startHeartbeat(): void { + if (this.heartbeatTimer !== undefined) return + this.heartbeatTimer = setInterval(() => { + for (const socket of this.server.clients) { + if (socket.readyState === WebSocket.OPEN) socket.ping() + } + }, this.heartbeatIntervalMs) + this.heartbeatTimer.unref() + } } interface ActiveStream { diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index d7289783d3..debb6763d5 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -5,6 +5,7 @@ import WebSocket, { type RawData } from 'ws' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import WebServer from '@deepseek-ai/dsh-host-webserver' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { bindTypertRemote, Remote, @@ -17,6 +18,7 @@ import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import { provideBrowserCredentials } from './browser-credentials.ts' import TypertGatewayService, { TypertGatewayError, + type Config as GatewayConfig, type TypertRemoteEventDispatch, type TypertRemoteEventInvocation, type TypertRemoteEventOutcome, @@ -211,6 +213,15 @@ afterEach(async () => { }) describe('Typert Remote streams', () => { + it('validates the WebSocket heartbeat timer range', () => { + expect(TypertGatewayService.Config({})).toEqual({ websocketHeartbeatIntervalMs: 30_000 }) + expect(TypertGatewayService.Config({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS })) + .toEqual({ websocketHeartbeatIntervalMs: MAX_TIMER_DELAY_MS }) + for (const websocketHeartbeatIntervalMs of [0, 1.5, MAX_TIMER_DELAY_MS + 1]) { + expect(() => TypertGatewayService.Config({ websocketHeartbeatIntervalMs })).toThrow() + } + }) + it('opens decoded carrier payloads through the in-process wire adapter', async () => { const { ctx } = await setup(false) const source = await ctx.typertGateway.wireStream.open( @@ -280,6 +291,19 @@ describe('Typert Remote streams', () => { })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) }) + it('uses the configured WebSocket heartbeat interval', { timeout: 1_000 }, async () => { + const { ctx } = await setup(true, { websocketHeartbeatIntervalMs: 20 }) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + const ping = once(socket, 'ping') + await once(socket, 'open') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + + socket.close() + await once(socket, 'close') + }) + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { const { ctx, service } = await setup(true) const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -964,7 +988,10 @@ describe('Typert Remote streams', () => { }) }) -async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { +async function setup( + transport: boolean, + gatewayConfig: GatewayConfig = {}, +): Promise<{ readonly ctx: Context; readonly service: FeedService }> { const ctx = new Context() roots.push(ctx) if (transport) { @@ -972,7 +999,7 @@ async function setup(transport: boolean): Promise<{ readonly ctx: Context; reado provideBrowserCredentials(ctx) } await ctx.plugin(TypertRegistry) - await ctx.plugin(TypertGatewayService) + await ctx.plugin(TypertGatewayService, gatewayConfig) if (transport) { await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) } diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts index 9746943b63..2cc4c99f8c 100644 --- a/packages/api/gateway/tests/stream-server.host.spec.ts +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -25,6 +25,31 @@ afterEach(async () => { }) describe('Remote stream mux server carrier lifecycle', () => { + it('sends WebSocket Ping control frames without application messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal), 20) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const messages = vi.fn() + client.on('message', messages) + + const ping = once(client, 'ping') + const pong = once(serverSocket, 'pong') + expect((await ping)[0]).toEqual(Buffer.alloc(0)) + expect((await pong)[0]).toEqual(Buffer.alloc(0)) + expect(messages).not.toHaveBeenCalled() + + const closingPing = vi.spyOn(serverSocket, 'ping') + client.pause() + serverSocket.close() + expect(serverSocket.readyState).toBe(WebSocket.CLOSING) + await new Promise((resolve) => { setTimeout(resolve, 25) }) + expect(closingPing).not.toHaveBeenCalled() + + const closed = once(client, 'close') + client.resume() + await closed + }) + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) @@ -168,8 +193,8 @@ const mapFailure: RemoteStreamFailureMapper = error => ({ details: {}, }) -async function startMux(open: RemoteStreamOpener): Promise { - const mux = new RemoteStreamMuxServer(open, mapFailure) +async function startMux(open: RemoteStreamOpener, heartbeatIntervalMs = 30_000): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure, heartbeatIntervalMs) const http = createServer() http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) await new Promise((resolve, reject) => { diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 46d1b3a88d..54d5f964f3 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -19,6 +19,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, { "path": "../../runtime-diagnostics/invariants" }, @@ -30,6 +33,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/timeout" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a1cf4046a..83e99c0ed7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -627,9 +627,15 @@ importers: packages/api/gateway: dependencies: + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery ws: specifier: ^8.21.0 version: 8.21.0 From 397fb929def1c5fb7af019a84b7f82ad96d267cf Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 27 Aug 2026 14:50:21 +0800 Subject: [PATCH 057/130] test(host): drain Include write queue in picker composition spec Replace the debounce-timer polling with Include.stop(), which flushes the file-backed write queue deterministically. This avoids Windows coverage flakes where the self-dispose write could land after the expect.poll timeout. --- .../tests/loader-composition.spec.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 783e4e1e6f..29bf3ed30a 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -138,6 +138,14 @@ function entryNames(ctx: Context): string[] { return [...ctx.loader.entries()].map(entry => entry.options.name) } +/** The Include tree that backs the booted `cordis.yml` file. */ +function includeTree(ctx: Context): Include { + const include = [...ctx.loader.entries()] + .find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined + if (include === undefined) throw new Error('expected the root Include tree') + return include +} + /** * Force every signal of an attended host on any platform: no SSH launch, a * display, and a PATH holding one executable chooser binary so the real @@ -188,10 +196,10 @@ describe('real Loader composition', () => { // behavior, not the chooser's); await that debounced write so it cannot // race the temp-dir removal, and pin that the persisted row is the // chooser itself — the resolved backend still never reaches the file. - await expect.poll( - async () => await readFile(configPath, 'utf8'), - { timeout: 15_000 }, - ).toContain('disabled: true') + // stop() drains the Include write queue, so this assertion does not depend + // on the debounce timer racing Windows coverage load. + await includeTree(ctx).stop() + expect(await readFile(configPath, 'utf8')).toContain('disabled: true') expect(await readFile(configPath, 'utf8')).not.toContain(NATIVE) }) @@ -240,8 +248,10 @@ describe('real Loader composition', () => { 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') + // Same self-dispose persistence as above: drain the Include write queue + // deterministically before asserting the persisted row. + await includeTree(ctx).stop() + expect(await readFile(configPath, 'utf8')).toContain('disabled: true') expect(renameControl.injectedFailures).toBe(1) expect(renameControl.remainingFailures).toBe(0) expect(renameControl.attempts).toBeGreaterThanOrEqual(2) @@ -251,9 +261,7 @@ describe('real Loader composition', () => { stubAttendedHost() const { ctx } = await loadComposition('127.0.0.1') const autoEntry = [...ctx.loader.entries()].find(entry => entry.options.name === AUTO)! - const include = [...ctx.loader.entries()] - .find(entry => entry.options.name === 'cordis:include')?.subtree as Include | undefined - if (include === undefined) throw new Error('expected the root Include tree') + const include = includeTree(ctx) renameControl.failureCode = 'EIO' renameControl.remainingFailures = 1 From 94d06e23d22e66be703f93cead2dfc069d30ee4e Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 15:11:55 +0800 Subject: [PATCH 058/130] fix(web): preserve mixed ask-user results --- .../tool/toolviews/ask-question-row.tsx | 17 +++++++++------- .../tests/ask-question-row.client.spec.tsx | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 272a1b4f22..44fca5b9e4 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import type { AskQuestionCardModel } from '../models/ask-question-card-model.ts' +import { singleResultText } from '../models/raw-tool-call.ts' import { toolRowModel } from '../models/tool-call-model.ts' import { ToolRow } from '../components/ToolRow.tsx' import { CONVERSATION_NS as NS } from '../../locale.ts' @@ -166,13 +167,15 @@ export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowPr } else if (model.state === 'running') { summary = t('ask.waiting') } else if ('kind' in block && model.state === 'ok') { - const text = block.content.filter(b => b.type === 'text').map(b => b.text).join('') - const presentation = answeredPresentation(argsRaw, text, t) - // Full transcripts require stable ids and valid visible fields; retain the - // legacy best-effort count when only strict pairing is unsafe. - summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary - if (presentation?.questions !== null && presentation?.questions !== undefined) { - transcript = { kind: 'answered', questions: presentation.questions, skippedLabel: t('ask.skipped') } + const text = singleResultText(block) + if (text !== undefined) { + const presentation = answeredPresentation(argsRaw, text, t) + // Full transcripts require stable ids and valid visible fields; retain the + // legacy best-effort count when only strict pairing is unsafe. + summary = presentation?.summary ?? answeredSummary(text, t) ?? model.summary + if (presentation?.questions !== null && presentation?.questions !== undefined) { + transcript = { kind: 'answered', questions: presentation.questions, skippedLabel: t('ask.skipped') } + } } } return ( diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index c371d0bce2..22f1e0d5e4 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -84,6 +84,26 @@ describe('AskQuestionRow', () => { expect(screen.queryByText(/"answers"/)).toBeNull() }) + it('keeps generic diagnostics when a valid answer result includes a non-text block', () => { + const resultText = answers([ + { id: 'goal', selected: ['Develop a feature'] }, + { id: 'scope', selected: ['deepseek-harness'] }, + { id: 'notes', selected: [] }, + ]) + const view = render() + + expect(screen.getByText(`ask_user_question · ${READABLE_ARGS}`)).toBeTruthy() + fireEvent.click(screen.getByRole('button', { expanded: false })) + expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull() + expect(view.container.textContent).toContain('"type": "reasoning"') + expect(view.container.textContent).toContain('"text": "unexpected diagnostic"') + }) + it('skipped questions (no selection, no custom) stay out of the answered count', () => { const view = render( Date: Thu, 27 Aug 2026 15:19:43 +0800 Subject: [PATCH 059/130] fix(subprocess): fence each signal against current process state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the shared observation defeated the very fence it fed: it carries the original PID-to-start-time pairing forward, so a recycled PID still matches it and takes a signal meant for the process that exited. Capturing it outside the per-member try also let one failed read abort a whole teardown round, breaking the synchronous host-exit contract, and an empty round paid a read for no members. signalProcess now reads ProcessInspector.isAlive immediately before delivering, from the narrowest per-identity source each platform offers; signalMembers and waitForMembers return before capturing when a round has no members. snapshot() keeps serving the readiness poll, whose per-poll table read stays at one. Windows enumerates Toolhelp32 lazily on the first tree question, so a snapshot asked only for liveness — the 25 ms teardown poll — performs no table walk at all. --- ...26-08-27-process-table-snapshots.i18n.yaml | 4 +- .../2026-08-27-process-table-snapshots.md | 16 ++++-- .../2026-08-27-process-table-snapshots.zh.md | 16 ++++-- .../subprocess-local/src/process-inspector.ts | 55 ++++++++++++++----- .../subprocess-local/src/terminal.ts | 18 +++--- .../subprocess-local/src/windows-inspector.ts | 27 +++++---- .../subprocess-local/tests/local.spec.ts | 2 + .../tests/process-exit.spec.ts | 3 +- .../tests/process-inspector.spec.ts | 31 ++++++++--- .../subprocess-local/tests/terminal.spec.ts | 47 ++++++++++++++-- .../tests/windows-inspector.spec.ts | 49 ++++++++++++----- .../terminal-bash/tests/session.spec.ts | 1 + 12 files changed, 196 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml index 01a60f2417..138ca27a49 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.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-27-process-table-snapshots.md -2026-08-27-process-table-snapshots.md: 2f031cc2952ffe1c007e04acf4dabbeac0630630 -2026-08-27-process-table-snapshots.zh.md: fbad15c306d250aeb64247a301ed20777f39c4a3 +2026-08-27-process-table-snapshots.md: 27c364607ca1e03a926c309f26007477a8785636 +2026-08-27-process-table-snapshots.zh.md: 9c5fe65bb83a0d04fe5639b3ffefcf377c3588ef diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md index 2f031cc295..27c364607c 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.md @@ -30,19 +30,23 @@ Teardown has the same structure. `signalProcess` fences each signal against PID Each caller captures one snapshot and answers every question of a single pass from it. `LocalTerminalHandle.descendants()` takes a snapshot, reads the tree and session from it, and filters survivors through the same `alive`, so a readiness poll costs one table read regardless of descendant count. `waitForMembers` captures a fresh snapshot per polling iteration, because its whole purpose is observing change. -`signalProcess(identity, signal, observed)` takes the caller's observation rather than reading the table itself. The PID-reuse fence stays, and `signalMembers` now captures once for a whole signalling round instead of once per member. Passing the observation explicitly is what keeps Linux teardown from regressing: `alive` there is answered from a `/proc` walk the snapshot already paid for, not from a fresh walk per member. +Signalling does not share that observation. `ProcessInspector.isAlive(identity)` answers current state from the narrowest per-identity source a platform offers — one `/proc//stat` read on Linux, one `ps` table on macOS, one process-handle check on Windows — and `signalProcess` takes that fence immediately before delivering the signal. An observation cannot stand in for it: the observation preserves the original PID-to-start-time pairing, so a recycled PID would still match it and take a signal meant for the process that exited. Reading the fence per target also keeps a failed read costing one target instead of the rest of a teardown round, which is what the [synchronous exit-cleanup contract](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.md) requires. + +`signalMembers` and `waitForMembers` return before capturing anything when a round has no members, so a command that spawned no descendants pays no table read for its teardown sweeps. Platform differences live in how a snapshot is built, not in what it promises: - **macOS** builds it from one `ps` table. That table exposes neither a session id nor a state column, so `session` is empty and `alive` reports presence with a matching start identity. - **Linux** walks `/proc` once, carrying each entry's parent, start identity, session, and state. `alive` treats the `Z`, `X`, and `x` states as quiescent, as a per-pid `stat` read did. -- **Windows** captures the Toolhelp32 enumeration for `tree`, has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there. +- **Windows** enumerates Toolhelp32 lazily, on the first `tree` question. It has no POSIX sessions, and answers `alive` from the live process handle, because wait state is not a table column there — so a snapshot asked only for liveness never enumerates. The terminal's Windows teardown polls liveness every 25 ms and would otherwise walk and discard the whole table each time. `PosixProcessSnapshot` holds both POSIX shapes: a row's `session` and `state` are `undefined` where the platform's table omits them, which is what makes the macOS answers fall out of the shared implementation instead of a second class. ## Testing -`packages/subprocess/subprocess-local/tests/terminal.spec.ts` drives a real `MacProcessInspector` over an injected `exec` and asserts one foreground inspection performs exactly one `-axo` table read at 0, 2, and 10 descendants. That count, not wall time, is the durable invariant: it holds on any host and fails the moment a caller re-reads the table per member. +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` drives a real `MacProcessInspector` over an injected `exec` and asserts one foreground inspection performs exactly one `-axo` table read at 0, 2, and 10 descendants. That count, not wall time, is the durable invariant: it holds on any host and fails the moment a caller re-reads the table per member. The same file pins that a signalling round with no members captures nothing, and that a capture failure during synchronous host exit still lets the PTY root be killed. + +`process-inspector.spec.ts` pins the fence directly: an identity observed alive and then absent from the table takes no signal. `windows-inspector.spec.ts` pins that a snapshot answering only liveness performs no Toolhelp32 enumeration. ## Alternatives considered @@ -50,7 +54,7 @@ Platform differences live in how a snapshot is built, not in what it promises: **Caching the macOS table inside `MacProcessInspector` behind a short TTL.** This needs no interface change, but it makes staleness invisible: a caller cannot tell whether a liveness answer came from this instant or from the end of the previous poll, and a signal decided on a stale row is exactly what the PID-reuse fence exists to prevent. Hidden caching also conflicts with the repository's preference for explicit defaulting and explicit boundaries. -**Keeping `isAlive` on the inspector next to `snapshot()`.** This avoids touching the signalling call sites, at the cost of two ways to ask one question, where only one of them is cheap in a loop. The asymmetry would have to be re-explained at every call site. +**Fencing signals with the round's shared observation.** This removes the last per-member read and was the shape first implemented here. Review rejected it: the fence exists to defeat PID reuse, and an observation defeats the fence instead, because it carries the original PID-to-start-time pairing forward. The window is narrow — the kills in one round are microseconds apart, against a PID space of 99999 on macOS and 4194304 on Linux — but the `README` states the guarantee without qualification, and buying microseconds of teardown time by weakening it is the wrong trade. Keeping both `snapshot().alive` and `isAlive` is therefore not two ways to ask one question: one asks what the table showed, the other asks what is true now, and only the second may decide a signal. **Making `exec` asynchronous instead of reducing the read count.** An async `execFile` stops the poll from blocking the loop but still forks N+1 processes per poll; on a busy machine that trades a stall for sustained fork pressure. It remains a worthwhile follow-up on top of the reduced count, not a substitute for it. @@ -58,9 +62,9 @@ Platform differences live in how a snapshot is built, not in what it promises: A readiness poll's process-table cost is now constant in descendant count. On macOS one poll performs one full table read plus the small `tpgid` read, which is the 0-descendant cost in the table above for every descendant count. -Liveness for a single identity on Linux costs a full `/proc` walk rather than one `stat` read. Every caller that asks about several identities amortizes that walk across them, which is why `signalProcess` takes an observation rather than capturing its own; a future caller that genuinely needs one isolated liveness answer pays more than it did. +Teardown keeps its previous per-signal cost: one narrow liveness read per target, which on macOS is one `ps` fork per member. That cost was never the measured problem — a terminal tears down once, while its readiness path polls up to 600 times — so the fix deliberately spends it to keep the fence reading current state. -A snapshot is a point-in-time view, and the type's documentation says so. Holding one across an `await` and then signalling from it would widen the PID-reuse window that the fence narrows; `waitForMembers` re-captures per iteration for exactly this reason. +A snapshot is a point-in-time view, and the type's documentation says so. `waitForMembers` re-captures per iteration because observing change is its purpose, and no signal is ever decided from a captured view. Every `ProcessInspector` implementation and test fake carries the new shape, including the Windows inspector and the `dsh-terminal-bash` session fake. Test fakes that previously replaced `processTree`, `processSession`, or `isAlive` to stage a scan now replace the corresponding per-question read hook, which keeps their staging behavior and call-counting identical. diff --git a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md index fbad15c306..9c5fe65bb8 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-process-table-snapshots.zh.md @@ -30,19 +30,23 @@ Status: implemented 每个调用方捕获一次快照,并从中回答本次流程的全部问题。`LocalTerminalHandle.descendants()` 取一次快照,从中读取树与会话,并用同一个 `alive` 过滤幸存者,因此一次就绪轮询无论有多少子进程都只读一次表。`waitForMembers` 每一轮轮询各捕获一次新快照,因为它的用途正是观察变化。 -`signalProcess(identity, signal, observed)` 接收调用方的观察,而不是自己去读表。PID 复用围栏保留,而 `signalMembers` 现在为整轮信号只捕获一次,而不是每个成员各一次。把观察显式传入正是 Linux 拆卸不退化的原因:那里的 `alive` 由快照已经付过代价的一次 `/proc` 遍历回答,而不是每个成员各遍历一次。 +发信号不共用这份观察。`ProcessInspector.isAlive(identity)` 用各平台最窄的按标识来源回答当前状态——Linux 读一个 `/proc//stat`、macOS 读一次 `ps` 表、Windows 查一次进程句柄——`signalProcess` 在投递信号前就地取这道围栏。观察无法代替它:观察把原始的「PID 与起始时间」配对保留了下来,因此被复用的 PID 仍会与之匹配,并领走本该发给已退出进程的信号。逐目标读取围栏还让一次失败的读取只损失一个目标,而不是整轮拆卸的其余部分,这正是[同步退出清理约定](../bug-fix/2026-08-11-synchronous-subprocess-exit-cleanup.zh.md)的要求。 + +`signalMembers` 与 `waitForMembers` 在一轮没有成员时直接返回、不做任何捕获,因此没有派生子进程的命令,其拆卸扫描不付表读取代价。 平台差异体现在快照如何构建,而不在它承诺什么: - **macOS** 由一张 `ps` 表构建。该表既不暴露会话 id 也不暴露状态列,所以 `session` 为空,`alive` 报告的是「存在且起始标识匹配」。 - **Linux** 遍历一次 `/proc`,携带每个条目的父进程、起始标识、会话与状态。`alive` 把 `Z`、`X`、`x` 状态视为静止,与按 pid 读 `stat` 的判定一致。 -- **Windows** 捕获 Toolhelp32 枚举供 `tree` 使用,没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列。 +- **Windows** 把 Toolhelp32 枚举惰性化到第一次 `tree` 提问。它没有 POSIX 会话,并且从活的进程句柄回答 `alive`,因为等待状态在那里不是表的一列——所以只问存活的快照永不枚举。终端在 Windows 上的拆卸每 25 ms 轮询一次存活,否则每次都会遍历整张表再丢弃。 `PosixProcessSnapshot` 同时承载两种 POSIX 形态:当平台的表省略某字段时,该行的 `session` 与 `state` 为 `undefined`,这使得 macOS 的答案从共享实现中自然得出,而不必新增一个类。 ## Testing -`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。 +`packages/subprocess/subprocess-local/tests/terminal.spec.ts` 通过注入的 `exec` 驱动真实的 `MacProcessInspector`,断言一次前台检查在 0、2、10 个子进程下都恰好执行一次 `-axo` 表读取。这个次数——而非墙钟时间——才是持久不变量:它在任何主机上都成立,并且在任何调用方按成员重复读表的那一刻失败。同一文件还钉住:没有成员的一轮信号不做任何捕获;同步主机退出期间捕获失败时,PTY root 仍会被杀掉。 + +`process-inspector.spec.ts` 直接钉住围栏:一个先被观察为存活、随后从表中消失的标识不会收到信号。`windows-inspector.spec.ts` 钉住只回答存活的快照不执行 Toolhelp32 枚举。 ## Alternatives considered @@ -50,7 +54,7 @@ Status: implemented **在 `MacProcessInspector` 内部用短 TTL 缓存 macOS 的表。** 这不需要改接口,但它让陈旧性不可见:调用方无法分辨一个存活答案来自此刻还是来自上一次轮询结束时,而基于陈旧行发出的信号正是 PID 复用围栏要防止的事情。隐式缓存也与仓库偏好显式默认与显式边界的立场冲突。 -**在 `snapshot()` 旁保留 `isAlive`。** 这样不必改动发信号的调用点,代价是同一个问题有两种问法,而其中只有一种在循环里是廉价的。这种不对称将不得不在每个调用点重新解释一遍。 +**用整轮共享的观察来给信号加围栏。** 这能去掉最后一处按成员的读取,也是本次最初实现的形态。评审否决了它:围栏的存在就是为了击败 PID 复用,而观察反过来击败了围栏——因为它把原始的「PID 与起始时间」配对一路带了下来。窗口确实很窄(一轮里各次 kill 相隔微秒级,而 macOS 的 PID 空间是 99999、Linux 是 4194304),但 `README` 是无条件地声明这条保证的,用削弱它来换取微秒级的拆卸时间是错误的取舍。因此同时保留 `snapshot().alive` 与 `isAlive` 并不是同一个问题的两种问法:前者问表当时显示了什么,后者问此刻什么为真,而只有后者可以决定一次信号。 **把 `exec` 改成异步,而不是减少读取次数。** 异步的 `execFile` 能让轮询不再阻塞事件循环,但每次轮询仍然 fork N+1 个进程;在繁忙的机器上这是把一次停顿换成了持续的 fork 压力。它在减少读取次数之上仍是值得做的后续项,而不是它的替代。 @@ -58,9 +62,9 @@ Status: implemented 一次就绪轮询的进程表代价现在与子进程数量无关。在 macOS 上,一次轮询执行一次完整表读取加一次小的 `tpgid` 读取,也就是上表中 0 子进程那一行的代价,对任意子进程数量都成立。 -Linux 上查询单个标识的存活,代价从读一个 `stat` 文件变成一次完整的 `/proc` 遍历。每个要查询多个标识的调用方都会把这次遍历摊薄,这正是 `signalProcess` 接收观察而非自行捕获的原因;将来若有调用方确实只需要一次孤立的存活查询,它付出的代价会比过去高。 +拆卸保持原有的按次代价:每个目标一次窄的存活读取,在 macOS 上即每个成员一次 `ps` fork。这项代价从来不是实测到的问题——一个终端只拆卸一次,而它的就绪路径最多轮询 600 次——所以本次修复刻意付出它,以保证围栏读的是当前状态。 -快照是一个时间点视图,该类型的文档也这样声明。跨 `await` 持有一份快照再据此发信号,会扩大围栏本来要收窄的 PID 复用窗口;`waitForMembers` 每轮重新捕获正是为此。 +快照是一个时间点视图,该类型的文档也这样声明。`waitForMembers` 每轮重新捕获是因为观察变化正是它的用途;任何信号都不会从一份已捕获的视图上做决定。 每个 `ProcessInspector` 实现与测试替身都采用新形态,包括 Windows 检查器和 `dsh-terminal-bash` 的会话替身。此前通过替换 `processTree`、`processSession` 或 `isAlive` 来编排扫描的测试替身,现在替换对应的按问题读取钩子,其编排行为与调用计数保持不变。 diff --git a/packages/subprocess/subprocess-local/src/process-inspector.ts b/packages/subprocess/subprocess-local/src/process-inspector.ts index 1c89e8b5f1..08cec3b4ab 100644 --- a/packages/subprocess/subprocess-local/src/process-inspector.ts +++ b/packages/subprocess/subprocess-local/src/process-inspector.ts @@ -20,15 +20,16 @@ interface FileStatus { * One observation of the platform process table, shared by every question a * single readiness poll or teardown pass asks. * - * The table is read once, at capture — a `/bin/ps` fork on macOS, a `/proc` - * walk on Linux, a Toolhelp32 enumeration on Windows. Answering {@link tree}, - * {@link session}, or {@link alive} never re-reads it, which is what keeps a - * poll's cost independent of how many descendants the running command spawned. - * Windows liveness additionally consults the live process handle, because wait - * state is not a table column there. + * The table is read at most once, on the first question that needs it — a + * `/bin/ps` fork on macOS, a `/proc` walk on Linux, a Toolhelp32 enumeration on + * Windows. Later questions never re-read it, which is what keeps a poll's cost + * independent of how many descendants the running command spawned. Windows + * liveness needs no table at all: wait state is a per-handle question there, so + * a snapshot asked only for liveness never enumerates. * - * A snapshot is a point-in-time view. Take a fresh one per poll or teardown - * pass; a stale one must never decide that a process is still worth signalling. + * A snapshot answers what the process table showed, which is what batch + * filtering wants and what signalling must not use: {@link ProcessInspector.isAlive} + * is the fence a signal takes, because it reads current state instead. */ export interface ProcessSnapshot { /** @@ -64,17 +65,34 @@ export interface ProcessInspector { isStdinWaiting(pgid: number, shellPid: number): boolean /** * Read the process table once and answer tree, session, and liveness from it. - * @returns A point-in-time process-table observation. + * @returns A process-table observation whose reads are shared. */ snapshot(): ProcessSnapshot + /** + * Return whether the exact identity is a non-quiescent process right now. + * + * Reads the narrowest per-identity source the platform offers rather than a + * whole table, so a signalling round can re-check every target without + * paying for a scan. Callers filtering many members at once want + * {@link ProcessSnapshot.alive} instead. + * + * @param identity - PID plus start identity to match. + * @returns Whether that exact identity — not merely that PID — is running. + */ + isAlive(identity: ProcessIdentity): boolean signalGroup(pgid: number, signal: SubprocessTerminalSignal): void /** * Signal one exact process identity, fenced against PID reuse. + * + * The fence reads current state immediately before the signal. An observation + * taken earlier in the same round cannot stand in for it: the observation + * preserves the original PID-to-start-time pairing, so a recycled PID would + * still match and take a signal meant for the process that exited. + * * @param identity - PID plus start identity to signal. * @param signal - termination signal to deliver. - * @param observed - observation the identity fence reads; pass one taken for this teardown pass. */ - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void } /** Testable boundary around filesystem, process-table, and signal syscalls. */ @@ -333,13 +351,14 @@ abstract class PosixProcessInspector implements ProcessInspector { abstract foregroundPgid(shellPid: number): number | undefined abstract isStdinWaiting(pgid: number, shellPid: number): boolean abstract snapshot(): ProcessSnapshot + abstract isAlive(identity: ProcessIdentity): boolean signalGroup(pgid: number, signal: SubprocessTerminalSignal): void { this.internals.kill(-pgid, signal) } - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void { - if (observed.alive(identity)) this.internals.kill(identity.pid, signal) + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { + if (this.isAlive(identity)) this.internals.kill(identity.pid, signal) } } @@ -438,6 +457,11 @@ class LinuxProcessInspector extends PosixProcessInspector { return false } + isAlive(identity: ProcessIdentity): boolean { + const stat = readLinuxStat(this.internals, identity.pid) + return stat?.started === identity.started && !quiescent(stat.state) + } + snapshot(): ProcessSnapshot { return new PosixProcessSnapshot(numericEntries(this.internals, '/proc').flatMap((pid) => { const stat = readLinuxStat(this.internals, pid) @@ -483,6 +507,11 @@ class MacProcessInspector extends PosixProcessInspector { return false } + isAlive(identity: ProcessIdentity): boolean { + return macProcessTable(this.internals) + .some(entry => entry.pid === identity.pid && entry.started === identity.started) + } + snapshot(): ProcessSnapshot { return new PosixProcessSnapshot(macProcessTable(this.internals)) } diff --git a/packages/subprocess/subprocess-local/src/terminal.ts b/packages/subprocess/subprocess-local/src/terminal.ts index eb6bf618c1..624de54984 100644 --- a/packages/subprocess/subprocess-local/src/terminal.ts +++ b/packages/subprocess/subprocess-local/src/terminal.ts @@ -139,7 +139,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (this.exited) return if (this.rootIdentity !== undefined) { try { - this.inspector.signalProcess(this.rootIdentity, 'SIGKILL', this.inspector.snapshot()) + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') } catch (_rootExitedDuringHostExit) { // Exact identity signalling contains both exit races and PID reuse. } @@ -175,6 +175,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private async waitForMembers(members: ProcessIdentity[]): Promise { + if (members.length === 0) return [] const until = Date.now() + this.graceMs let survivors = this.survivors(members, this.inspector.snapshot()) while (survivors.length > 0 && Date.now() < until) { @@ -185,10 +186,11 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { } private signalMembers(members: ProcessIdentity[], signal: 'SIGTERM' | 'SIGKILL'): void { - const observed = this.inspector.snapshot() for (const member of members) { try { - this.inspector.signalProcess(member, signal, observed) + // Each signal reads its own identity fence, inside this try: a failed + // read must cost one target, never the rest of a teardown round. + this.inspector.signalProcess(member, signal) } catch (_alreadyExitedDuringSignal) { // The exact process identity is rechecked; a same-tick exit is success. } @@ -264,9 +266,9 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { // (the same console-list agent), so the tiers verify the shell's absence // through the inspector instead of waiting on `done` alone. const shellGone = (): boolean => - this.exited || (this.rootIdentity !== undefined && !this.inspector.snapshot().alive(this.rootIdentity)) + this.exited || (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) if (!shellGone() && this.rootIdentity !== undefined) { - this.inspector.signalProcess(this.rootIdentity, 'SIGTERM', this.inspector.snapshot()) + this.inspector.signalProcess(this.rootIdentity, 'SIGTERM') await this.waitForWindowsShellExit() } if (!shellGone() && this.rootIdentity === undefined) { @@ -278,7 +280,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { await Promise.race([this.done.then(() => undefined), delay(this.graceMs)]) } if (!shellGone() && this.rootIdentity !== undefined) { - this.inspector.signalProcess(this.rootIdentity, 'SIGKILL', this.inspector.snapshot()) + this.inspector.signalProcess(this.rootIdentity, 'SIGKILL') await this.waitForWindowsShellExit() } if (!shellGone()) throw new Error(`terminal cleanup failed; surviving pid: ${this.pid}`) @@ -287,7 +289,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { private async waitForWindowsShellExit(): Promise { const until = Date.now() + this.graceMs while (!this.exited && Date.now() < until) { - if (this.rootIdentity !== undefined && !this.inspector.snapshot().alive(this.rootIdentity)) return + if (this.rootIdentity !== undefined && !this.inspector.isAlive(this.rootIdentity)) return await delay(Math.min(25, Math.max(1, until - Date.now()))) } } @@ -317,7 +319,7 @@ export class LocalTerminalHandle implements SubprocessTerminalHandle { if (this.exited) return /* v8 ignore next -- stopShellWindows() verified the shell is gone or threw; the identity re-check is a defensive fence for a future caller. */ - if (this.rootIdentity !== undefined && this.inspector.snapshot().alive(this.rootIdentity)) return + if (this.rootIdentity !== undefined && this.inspector.isAlive(this.rootIdentity)) return this.exited = true this.output.end() this.outcome.resolve({ exitCode: null, signal: null }) diff --git a/packages/subprocess/subprocess-local/src/windows-inspector.ts b/packages/subprocess/subprocess-local/src/windows-inspector.ts index 7b2ae5a23d..6820505c3d 100644 --- a/packages/subprocess/subprocess-local/src/windows-inspector.ts +++ b/packages/subprocess/subprocess-local/src/windows-inspector.ts @@ -97,18 +97,25 @@ export class WindowsProcessInspector implements ProcessInspector { return false } + isAlive(identity: ProcessIdentity): boolean { + const state = this.internals.processState(identity.pid) + return state?.active === true && state.started === identity.started + } + snapshot(): ProcessSnapshot { - const entries = this.internals.snapshot() + // Enumerated on the first question that reads the table. Liveness never + // does — wait state is a per-handle question here — so the Windows + // teardown poll, which asks only for liveness, pays no Toolhelp32 walk. + let entries: ProcessEntry[] | undefined return { - tree: rootPid => windowsProcessTree(entries, rootPid, pid => this.internals.processState(pid)?.started), + tree: rootPid => windowsProcessTree( + entries ??= this.internals.snapshot(), + rootPid, + pid => this.internals.processState(pid)?.started, + ), // Windows has no POSIX sessions; the shell pid stands in as a pseudo group. session: () => [], - alive: (identity) => { - // Wait state is a per-handle question, not a Toolhelp32 column, so - // liveness reads the live process object rather than `entries`. - const state = this.internals.processState(identity.pid) - return state?.active === true && state.started === identity.started - }, + alive: identity => this.isAlive(identity), } } @@ -116,8 +123,8 @@ export class WindowsProcessInspector implements ProcessInspector { this.internals.taskkill(pgid, signal === 'SIGKILL') } - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot): void { - if (observed.alive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL'): void { + if (this.isAlive(identity)) this.internals.taskkill(identity.pid, signal === 'SIGKILL') } } /* jscpd:ignore-end */ diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index 589102ccf8..9e4925bdb2 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -303,6 +303,7 @@ describe('LocalSubprocessRuntime', () => { foregroundPgid: () => undefined, isStdinWaiting: () => false, snapshot: () => ({ tree: () => [], session: () => [], alive: () => false }), + isAlive: () => false, signalGroup: () => {}, signalProcess: () => {}, } @@ -372,6 +373,7 @@ describe('LocalSubprocessRuntime', () => { session: () => [], alive: identity => alive.has(identity.pid), }), + isAlive: identity => alive.has(identity.pid), signalGroup: () => {}, signalProcess: () => {}, } diff --git a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts index c00c666e33..1fc952a869 100644 --- a/packages/subprocess/subprocess-local/tests/process-exit.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-exit.spec.ts @@ -68,10 +68,9 @@ function cleanupTree(state: TreeState | undefined, identities: ProcessIdentity[] return } const inspector = createProcessInspector() - const observed = inspector.snapshot() for (const identity of identities) { try { - inspector.signalProcess(identity, 'SIGKILL', observed) + inspector.signalProcess(identity, 'SIGKILL') } catch (_alreadyGone) { // Exact start identity prevents PID-reuse cleanup from reaching another process. } diff --git a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts index ac5c95c13f..797076ebfc 100644 --- a/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/process-inspector.spec.ts @@ -138,14 +138,15 @@ describe('Linux process inspector', () => { expect(observed.alive({ pid: 10, started: '500' })).toBe(true) expect(observed.alive({ pid: 10, started: 'old' })).toBe(false) inspector.signalGroup(40, 'SIGINT') - inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM', observed) - inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL', observed) + inspector.signalProcess({ pid: 10, started: '500' }, 'SIGTERM') + inspector.signalProcess({ pid: 10, started: 'old' }, 'SIGKILL') expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) fake.files.set('/proc/10/stat', stat(10, 20, 30, 40, '500', 1, 'Z')) - // A zombie is present in the table but never signallable; a fresh capture sees the new state. - const afterExit = inspector.snapshot() - expect(afterExit.alive({ pid: 10, started: '500' })).toBe(false) - inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL', afterExit) + // A zombie is present in the table but never signallable; both the batch + // view and the signal fence report it quiescent once the state changes. + expect(inspector.snapshot().alive({ pid: 10, started: '500' })).toBe(false) + expect(inspector.isAlive({ pid: 10, started: '500' })).toBe(false) + inspector.signalProcess({ pid: 10, started: '500' }, 'SIGKILL') expect(fake.kills).toEqual([[-40, 'SIGINT'], [10, 'SIGTERM']]) }) @@ -298,8 +299,8 @@ describe('macOS process inspector', () => { expect(observed.session(10)).toEqual([]) expect(observed.alive({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' })).toBe(true) inspector.signalGroup(55, 'SIGTSTP') - inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL', observed) - inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM', observed) + inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL') + inspector.signalProcess({ pid: 12, started: 'missing' }, 'SIGTERM') expect(fake.kills).toEqual([[-55, 'SIGTSTP'], [11, 'SIGKILL']]) fake.setPs(' 10 11 Mon Jul 21 10:00:00 2026\n 11 10 Mon Jul 21 10:00:01 2026\n') @@ -309,6 +310,20 @@ describe('macOS process inspector', () => { ]) }) + it('re-reads the process table before signalling instead of trusting an earlier observation', () => { + const fake = fakeInternals() + fake.setPs(' 11 10 Mon Jul 21 10:00:01 2026\n') + const inspector = createProcessInspector('darwin', 'arm64', fake.internals) + inspector.snapshot() + // The member exits after that observation; a recycled pid would otherwise + // inherit the observed identity and take the signal meant for the original. + fake.setPs('') + + inspector.signalProcess({ pid: 11, started: 'Mon Jul 21 10:00:01 2026' }, 'SIGKILL') + + expect(fake.kills).toEqual([]) + }) + it('returns undefined for missing or invalid foreground groups and dispatches platform inspectors', () => { const fake = fakeInternals() fake.setTpgid('-1') diff --git a/packages/subprocess/subprocess-local/tests/terminal.spec.ts b/packages/subprocess/subprocess-local/tests/terminal.spec.ts index 75beef6a34..b8f23a89c4 100644 --- a/packages/subprocess/subprocess-local/tests/terminal.spec.ts +++ b/packages/subprocess/subprocess-local/tests/terminal.spec.ts @@ -76,23 +76,29 @@ class FakeInspector implements ProcessInspector { readTree: () => ProcessIdentity[] = () => this.root === undefined ? this.members : [this.root, ...this.members] readSession: () => ProcessIdentity[] = () => this.sessionMembers readAlive: (identity: ProcessIdentity) => boolean = identity => this.alive.has(identity.pid) + /** Liveness as of right now; tests diverge it from readAlive to stage an exit between scan and signal. */ + readCurrentAlive: (identity: ProcessIdentity) => boolean = identity => this.readAlive(identity) + /** Counts process-table captures so read-amplification cases can pin them. */ + captures = 0 snapshot(): ProcessSnapshot { + this.captures += 1 return { tree: () => this.readTree(), session: () => this.readSession(), alive: identity => this.readAlive(identity), } } + + isAlive(identity: ProcessIdentity) { return this.readCurrentAlive(identity) } signalGroup(pgid: number, signal: SubprocessTerminalSignal) { if (this.throwGroup) throw new Error('group failed') this.groups.push([pgid, signal]) } - signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL', observed: ProcessSnapshot) { + signalProcess(identity: ProcessIdentity, signal: 'SIGTERM' | 'SIGKILL') { // Mirrors the real inspectors' alive-gated signalling. - if (!this.alive.has(identity.pid)) return if (this.throwProcess) throw new Error('process raced') - if (!observed.alive(identity)) return + if (!this.isAlive(identity)) return this.processes.push([identity.pid, signal]) if (this.removeOnSignal) this.alive.delete(identity.pid) } @@ -116,8 +122,8 @@ describe('LocalTerminalHandle', () => { inspector.alive.add(pty.pid) inspector.alive.add(first.pid) const signalProcess = inspector.signalProcess.bind(inspector) - inspector.signalProcess = (identity, signal, observed) => { - signalProcess(identity, signal, observed) + inspector.signalProcess = (identity, signal) => { + signalProcess(identity, signal) if (identity.pid === pty.pid) { inspector.members = [first, late] inspector.alive.add(late.pid) @@ -527,6 +533,37 @@ describe('LocalTerminalHandle on Windows', () => { }) }) +describe('signalling freshness and containment', () => { + it('keeps synchronous host exit going when the process table cannot be captured', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(pty.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + inspector.snapshot = () => { throw new Error('process table unavailable') } + + expect(() => { handle.terminateForHostExit() }).not.toThrow() + + // forceStopShell still runs: a failed scan must not cost the PTY root. + expect(inspector.processes).toEqual([[pty.pid, 'SIGKILL']]) + }) + + it('captures no process table for a signalling round with no members', () => { + const pty = new FakePty() + const inspector = new FakeInspector() + inspector.alive.add(pty.pid) + const handle = new LocalTerminalHandle(pty.asPty(), inspector, 10) + // Only the shell exists, so every descendant scan yields an empty round. + inspector.readTree = () => [{ pid: pty.pid, started: 'shell' }] + inspector.captures = 0 + + handle.terminateForHostExit() + + // Two descendant scans and nothing else: no capture for either empty + // signalling round, and none for the identity-fenced shell kill. + expect(inspector.captures).toBe(2) + }) +}) + describe('process-table read amplification', () => { // The macOS inspector answers every question by forking `/bin/ps`, so a // readiness poll that asks per descendant scales its blocking cost with the diff --git a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts index c2129923b4..de8df8d535 100644 --- a/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts +++ b/packages/subprocess/subprocess-local/tests/windows-inspector.spec.ts @@ -16,10 +16,12 @@ function fakeInternals() { const entries: ProcessEntry[] = [] const states = new Map() const kills: Array<[number, boolean]> = [] + const counts = { enumerations: 0, stateReads: 0 } return { + counts, internals: { - snapshot: () => [...entries], - processState: pid => states.get(pid), + snapshot: () => { counts.enumerations += 1; return [...entries] }, + processState: (pid) => { counts.stateReads += 1; return states.get(pid) }, taskkill: (pid: number, force: boolean) => { kills.push([pid, force]) }, } satisfies WindowsProcessInspectorInternals, add(entry: ProcessEntry, started?: string, active = true): void { @@ -30,6 +32,29 @@ function fakeInternals() { } } +describe('WindowsProcessInspector table enumeration', () => { + it('enumerates the process table only for questions that need it', () => { + const fake = fakeInternals() + fake.add({ pid: 10, parentPid: 0 }, 't10') + fake.add({ pid: 11, parentPid: 10 }, 't11') + const inspector = new WindowsProcessInspector(fake.internals) + + // Liveness is a per-handle question on Windows, so a snapshot asked only + // for liveness must not pay a Toolhelp32 walk. The terminal's Windows + // teardown polls exactly this way, every 25 ms. + const observed = inspector.snapshot() + expect(observed.alive({ pid: 11, started: 't11' })).toBe(true) + expect(fake.counts.enumerations).toBe(0) + + expect(observed.tree(10)).toHaveLength(2) + expect(fake.counts.enumerations).toBe(1) + + // A second tree question reuses the same observation. + observed.tree(10) + expect(fake.counts.enumerations).toBe(1) + }) +}) + describe('windowsProcessTree', () => { it('walks a table children-first with readable identities only', () => { const started = (pid: number): string | undefined => pid === 12 ? undefined : `t${pid}` @@ -78,12 +103,12 @@ describe('WindowsProcessInspector (injected internals)', () => { { pid: 11, started: 't11' }, { pid: 10, started: 't10' }, ]) - expect(inspector.snapshot().alive({ pid: 11, started: 't11' })).toBe(true) - expect(inspector.snapshot().alive({ pid: 11, started: 'stale' })).toBe(false) - expect(inspector.snapshot().alive({ pid: 99, started: 't99' })).toBe(false) + expect(inspector.isAlive({ pid: 11, started: 't11' })).toBe(true) + expect(inspector.isAlive({ pid: 11, started: 'stale' })).toBe(false) + expect(inspector.isAlive({ pid: 99, started: 't99' })).toBe(false) fake.add({ pid: 12, parentPid: 10 }, 't12', false) - expect(inspector.snapshot().alive({ pid: 12, started: 't12' })).toBe(false) + expect(inspector.isAlive({ pid: 12, started: 't12' })).toBe(false) }) it('maps SIGKILL to a forced taskkill and other signals to the grace form', () => { @@ -100,9 +125,9 @@ describe('WindowsProcessInspector (injected internals)', () => { fake.add({ pid: 10, parentPid: 0 }, 't10') fake.add({ pid: 11, parentPid: 10 }, 't11', false) const inspector = new WindowsProcessInspector(fake.internals) - inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL', inspector.snapshot()) - inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL', inspector.snapshot()) - inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM', inspector.snapshot()) + inspector.signalProcess({ pid: 10, started: 't10' }, 'SIGKILL') + inspector.signalProcess({ pid: 11, started: 't11' }, 'SIGKILL') + inspector.signalProcess({ pid: 10, started: 'stale' }, 'SIGTERM') expect(fake.kills).toEqual([[10, true]]) }) @@ -139,12 +164,10 @@ win32('WindowsProcessInspector over the real koffi bindings', () => { it('reports unreadable identities for absent processes and no-ops tree signalling', () => { const inspector = createWindowsProcessInspector() - expect(inspector.snapshot().alive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) + expect(inspector.isAlive({ pid: 0x7FFFFFFF, started: 'absent' })).toBe(false) expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGKILL') }).not.toThrow() expect(() => { inspector.signalGroup(0x7FFFFFFF, 'SIGTERM') }).not.toThrow() expect(() => { inspector.signalGroup(0, 'SIGKILL') }).not.toThrow() - expect(() => { - inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL', inspector.snapshot()) - }).not.toThrow() + expect(() => { inspector.signalProcess({ pid: 0x7FFFFFFF, started: 'absent' }, 'SIGKILL') }).not.toThrow() }) }) diff --git a/packages/terminal/terminal-bash/tests/session.spec.ts b/packages/terminal/terminal-bash/tests/session.spec.ts index 02ce95db02..b28c579375 100644 --- a/packages/terminal/terminal-bash/tests/session.spec.ts +++ b/packages/terminal/terminal-bash/tests/session.spec.ts @@ -34,6 +34,7 @@ class FakeInspector implements ProcessInspector { alive: (identity: ProcessIdentity) => this.alive.has(identity.pid), } } + isAlive(identity: ProcessIdentity) { return this.alive.has(identity.pid) } signalGroup(pgid: number, signal: TerminalSignal) { if (this.throwGroup) throw new Error('group failed') this.groups.push([pgid, signal]) From f9770e34aff530b52f11ef7e37fab4bfb72210f4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 15:20:03 +0800 Subject: [PATCH 060/130] fix(tools): keep PTC SDK calls inside run_code --- ...026-08-07-code-mode-executor-collapse.i18n.yaml | 4 ++-- .../2026-08-07-code-mode-executor-collapse.md | 2 +- .../2026-08-07-code-mode-executor-collapse.zh.md | 2 +- packages/core/tools/README.i18n.yaml | 4 ++-- packages/core/tools/README.md | 10 +++++++--- packages/core/tools/README.zh.md | 10 +++++++--- packages/core/tools/src/ts-types.ts | 8 ++++++-- packages/core/tools/tests/ts-types.spec.ts | 14 +++++++++++++- .../both-mode-turn/system-prompt.expected.md | 8 ++++++-- .../code-mode-read-image/system-prompt.expected.md | 8 ++++++-- .../code-mode-turn/system-prompt.expected.md | 8 ++++++-- .../cordis-inspect-jsdoc/system-prompt.expected.md | 8 ++++++-- .../web/code-mode-round/system-prompt.expected.md | 8 ++++++-- 13 files changed, 69 insertions(+), 25 deletions(-) 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 index 9db96229ec..7479e32937 100644 --- 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 @@ -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-07-code-mode-executor-collapse.md -2026-08-07-code-mode-executor-collapse.md: b19d0842d8b58b170a0f6839dcb32b9390211ca0 -2026-08-07-code-mode-executor-collapse.zh.md: 38bb319c8ce8fd01ab42ad29a6b3748c5d0f7925 +2026-08-07-code-mode-executor-collapse.md: 0644342de1583d6c2a6d036027195edb6e33de73 +2026-08-07-code-mode-executor-collapse.zh.md: 013b2c19399da8ceed33e3455e30862c8c7f0363 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 index b19d0842d8..0644342de1 100644 --- 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 @@ -42,5 +42,5 @@ No provider guarantees interception of unadvertised names; the reported session - `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-observation-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 first-party per-tool guidance. 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. +- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of first-party per-tool guidance. 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. The TypeScript SDK section repeats the distinction next to the generated declarations, labels them as program-only bindings, states that only separately supplied tool schemas grant direct-call availability, and shows a complete `run_code` call around `tools.bash(...)` because the declaration list can otherwise be read as native tool availability. `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 index 38bb319c8c..013b2c1939 100644 --- 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 @@ -42,5 +42,5 @@ guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装 - `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-observation-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 -- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 +- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。TypeScript SDK 段在生成声明旁再次区分两者,将其标为只能在程序内使用的绑定,说明只有单独提供的工具 schema 才赋予直呼权限,并给出以 `run_code` 包住 `tools.bash(...)` 的完整调用,因为声明列表可能被误读为原生工具可用性。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 - 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 07832243a0..ae1208ad4a 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: eba0fb8bed0a4117131027704cfc37c968de3c8d -README.zh.md: 175ec80206fe61480ada83a9aa8a94803558cc7f +README.md: 86c2f3549248d7c93f4bd4bc9ca10c8ab64b2bb0 +README.zh.md: caffa098090b142e4fd584410ac12a4c2574a6b0 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index eba0fb8bed..86c2f35492 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -168,21 +168,25 @@ 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 `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this Code Mode API; under `code` the prompt also carries the `tools:code-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. +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 instructions identify generated declarations as program-only bindings and show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this Code Mode API; under `code` the prompt also carries the `tools:code-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. ##### Code Mode SDK instructions ```markdown ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ``` #### Token effect diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 175ec80206..caffa09809 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -168,21 +168,25 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 Code Mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:code-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。说明会把生成声明明确标为只能在程序内使用的绑定,并给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 Code Mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:code-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 ##### Code Mode SDK 说明 ```markdown ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ``` #### Token 影响 diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 50d217e2aa..7ffc1a5c6e 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -249,14 +249,18 @@ export function jsonSchemaToTs(schema: unknown, indent = 0): string { /** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */ const SDK_INSTRUCTIONS = `## Writing code for run_code -\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. Inside the program: +\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate \`bash\` schema is supplied, invoke a declared \`bash\` binding inside \`run_code\`: + +\`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })\` + +Inside the program: - Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue. - Independent read-only calls MAY overlap under \`Promise.all\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`. - Emit results with \`return\` and/or \`console.log(...)\`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools:` +Program-only SDK bindings:` /** * Render the full `tools:sdk` prompt section: the fixed usage instructions diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tools/tests/ts-types.spec.ts index 8b4e145060..39179086b9 100644 --- a/packages/core/tools/tests/ts-types.spec.ts +++ b/packages/core/tools/tests/ts-types.spec.ts @@ -110,7 +110,10 @@ describe('renderToolsSdk', () => { const bash: ToolSdkSchema = { name: 'bash', description: 'Run a shell command.', - parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record, + parameters: parameterSchemaSpecToJsonSchema({ + command: { type: 'string', required: true }, + description: { type: 'string', required: true }, + }) as unknown as Record, output: { type: 'object', additionalProperties: false, @@ -157,6 +160,15 @@ describe('renderToolsSdk', () => { expect(text).toContain('two required arguments') }) + it('keeps generated bindings inside a run_code program', () => { + const text = renderToolsSdk([bash]) + expect(text).toContain('A declaration does not make its name a directly callable tool') + expect(text).toContain('only names supplied as separate tool schemas may be called directly') + expect(text).toContain('`run_code({ code: "return await tools.bash(') + expect(text).toContain('Program-only SDK bindings:') + expect(text).not.toContain('The available tools:') + }) + it('is deterministic: same tool set, byte-identical text regardless of input order', () => { expect(renderToolsSdk([bash, exotic])).toBe(renderToolsSdk([exotic, bash])) // Equal names sort stably (the comparator's equal arm). diff --git a/snapshots/session/both-mode-turn/system-prompt.expected.md b/snapshots/session/both-mode-turn/system-prompt.expected.md index 9efdde3cf9..8511e0eff3 100644 --- a/snapshots/session/both-mode-turn/system-prompt.expected.md +++ b/snapshots/session/both-mode-turn/system-prompt.expected.md @@ -31,14 +31,18 @@ Use subagent in the background by default. Start independent delegations togethe ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ```ts type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } diff --git a/snapshots/session/code-mode-read-image/system-prompt.expected.md b/snapshots/session/code-mode-read-image/system-prompt.expected.md index 3e09ce8079..42106b19ac 100644 --- a/snapshots/session/code-mode-read-image/system-prompt.expected.md +++ b/snapshots/session/code-mode-read-image/system-prompt.expected.md @@ -33,14 +33,18 @@ Use subagent in the background by default. Start independent delegations togethe ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ```ts type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } diff --git a/snapshots/session/code-mode-turn/system-prompt.expected.md b/snapshots/session/code-mode-turn/system-prompt.expected.md index f297328c55..ed2959eb01 100644 --- a/snapshots/session/code-mode-turn/system-prompt.expected.md +++ b/snapshots/session/code-mode-turn/system-prompt.expected.md @@ -33,14 +33,18 @@ Use subagent in the background by default. Start independent delegations togethe ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ```ts type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } diff --git a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md index 843da9da21..93ad87e990 100644 --- a/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md +++ b/snapshots/session/cordis-inspect-jsdoc/system-prompt.expected.md @@ -137,14 +137,18 @@ Use subagent in the background by default. Start independent delegations togethe ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ```ts type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } diff --git a/snapshots/web/code-mode-round/system-prompt.expected.md b/snapshots/web/code-mode-round/system-prompt.expected.md index 4ef0f6c114..6cda9dc20d 100644 --- a/snapshots/web/code-mode-round/system-prompt.expected.md +++ b/snapshots/web/code-mode-round/system-prompt.expected.md @@ -40,14 +40,18 @@ Use subagent_fork in the background by default. Start independent delegations to ## Writing code for run_code -`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. Inside the program: +`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate `bash` schema is supplied, invoke a declared `bash` binding inside `run_code`: + +`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })` + +Inside the program: - Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue. - Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`. - Emit results with `return` and/or `console.log(...)`. Only what you print or return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need. -The available tools: +Program-only SDK bindings: ```ts type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue } From 8e9da9debf2b0afb27ae896c5197d34b748c4d3d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 15:21:08 +0800 Subject: [PATCH 061/130] refactor(session-reference): label discovery from projections alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A title now comes from an attached session's live projection cut or a cold one's durable checkpoint, and from nothing else. Attachment is decided by the session store at read time, so a session that attached after the listing is no longer answered from a checkpoint its log has moved past — the stale-title case `api-session.list` already handles this way. The log fold and its per-log memo are gone. Folding one title costs a whole log, and this call sits under every keystroke; a session no projection answers for is labeled by its id and regains its title the first time it is opened. `lib` leaves the default exclusions: Ruby gems and many npm packages keep sources there, and the miss would be silent and total. A traversal whose root is unreadable now rejects instead of publishing an empty index over entries that are still good, which is what the stale-while-revalidate path claimed but could not do while every readdir error was swallowed. A drill marks the menu drilled only when its edit actually reached the draft. Refs #3154 Refs #3180 --- ...ention-discovery-and-row-content.i18n.yaml | 4 +- ...eb-at-mention-discovery-and-row-content.md | 22 +- ...at-mention-discovery-and-row-content.zh.md | 22 +- .../reference-composer/menu.expected.md | 4 +- apps/web/tests/reference-composer.e2e.ts | 23 ++- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 8 +- docs/subsystems/session-reference.zh.md | 8 +- .../ui-input-trigger/src/client/controller.ts | 11 +- packages/client/ui-input-trigger/src/types.ts | 9 +- .../tests/service.client.spec.ts | 15 +- .../file-reference-local/README.i18n.yaml | 4 +- .../context/file-reference-local/README.md | 6 +- .../context/file-reference-local/README.zh.md | 6 +- .../file-reference-local/src/search.ts | 27 ++- .../file-reference-local/tests/search.spec.ts | 66 ++++-- .../session-reference/README.i18n.yaml | 4 +- packages/context/session-reference/README.md | 4 +- .../context/session-reference/README.zh.md | 4 +- .../context/session-reference/src/index.ts | 195 ++++-------------- .../tests/session-reference.spec.ts | 180 +++++----------- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 22 files changed, 248 insertions(+), 380 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml index 8b48c9526c..81fd019718 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.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-27-web-at-mention-discovery-and-row-content.md -2026-08-27-web-at-mention-discovery-and-row-content.md: 49587a35411952ddb270e2dc2687dcc98b3bebe5 -2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 0a26f5ddc2cb1469c2b69c160a134e36cd84586e +2026-08-27-web-at-mention-discovery-and-row-content.md: ae27f98b07d7a837e095f95129b355770fc8ac02 +2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8569473d1dec28f371ae8cc12ed50081d2642ab4 diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md index 49587a3541..ae27f98b07 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md @@ -18,27 +18,29 @@ Web e2e could not see any of this: its scaffold pins an isolated `DSH_HOME` hold ## Decision -**Discovery cost tracks projection-cache coverage.** `SessionReferenceResolver` labels candidates from `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])`, a synchronous in-memory read that `api-session.list` already uses. A session the cache has checkpointed costs no log read. Every other session is folded once and memoized on the resolver for the process lifetime, keyed by the header's creation facts so a reused id cannot inherit a stale title; a session that is attached again is never memoized, because its log is still growing. A non-empty query folds the uncheckpointed remainder before filtering, because the filter reads labels — deferring that fold to the capped page would make a session unfindable by its own title. An empty query filters nothing, so its unresolved tail waits for the page. +**A discovery label is a projection read, never a log read.** `SessionReferenceResolver` asks each listed session's projections for its title and takes its id when none answers. Attachment is decided by the session store at read time, not by the listing that produced the record, so a session that attached in between is never answered from a checkpoint its live log has moved past. An attached session answers from `ctx.sessionProjections.snapshot(session, ['title'])` — the live cut, which advances with every committed event, over events already in memory. A cold one answers from `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])`, the durable checkpoint written when it went cold. Both are synchronous and touch no log. -The cache is optional, and without it the previous fold path stands unchanged, including its limitation that an unfiltered listing folds only its cwd-ranked head. +Folding a title from a log costs the whole log, and this call sits under every keystroke of `@` completion, so it is not attempted at all. A session no projection answers for — one persisted before the cache was composed, or seeded straight to disk — is labeled by its id and cannot be found by its title. That state is self-healing: opening the session once attaches it, and disposal checkpoints it. -**An invalidated file index keeps answering while its replacement builds.** `invalidate()` bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A failed refresh leaves the stale entries and the counter behind, so the next query retries. `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` grows from two names to sixteen — version-control and dependency stores plus the build-output basenames of the ecosystems this harness runs in — and `DEFAULT_FILE_SEARCH_MAX_ENTRIES` rises to 50 000. Both remain `excludedDirectories` and `maxEntries` config fields a deployment overrides. +**An invalidated file index keeps answering while its replacement builds.** `invalidate()` bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A traversal whose root is unreadable rejects rather than settling: an unreadable branch costs its own candidates, but an unreadable root learned nothing, and publishing that as an empty index would replace entries that are still good and leave no invalidation to retry from. A failed refresh leaves the stale entries and the counter behind, so the next query retries. `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` grows from two names to fifteen — version-control and dependency stores plus build-output basenames no ecosystem also uses for sources — and `DEFAULT_FILE_SEARCH_MAX_ENTRIES` rises to 50 000. Both remain `excludedDirectories` and `maxEntries` config fields a deployment overrides. **Rows carry only what distinguishes them.** A file names its parent directory and nothing at the workspace root. A drilled directory listing names no parent, because its breadcrumb does. A session names its workspace only when `SessionReferenceCandidate.sameWorkspace` is false — the host computes that, since it already holds both working directories for ranking — and is dated from the Host session list's `updatedAt` through the relative-time bucket that list uses, so one session reads the same age on both surfaces. A session the list does not carry falls back to the candidate's `createdAt`. `relativeTime` moves from `ui-workspace`'s `tree.ts` to `ui-primitives`; the words stay in each plugin's own dictionary, per locale-owned copy. The session id leaves the row: it is already the label a session without a title falls back to. -**A drill publishes a breadcrumb; typing a path does not.** `InputTriggerSource` gains an optional synchronous `header(session, req)` hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned `drilled` flag that says whether a drill or typing produced it. `CandidateRequest` carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through `onPick` with `action: 'drill'` — returning to a step and descending into one are one outcome. `MenuView` renders the header above its scrolling viewport and moves `role="listbox"` onto that viewport, because a breadcrumb is not an option and a listbox may not carry one. +**A drill publishes a breadcrumb; typing a path does not.** `InputTriggerSource` gains an optional synchronous `header(session, req)` hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned `drilled` flag. The flag is set only when the drill's edit actually reached the draft — a refused edit leaves it clear, so a header never names a directory nobody descended into — and it survives further typing until the menu closes. `CandidateRequest` carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through `onPick` with `action: 'drill'` — returning to a step and descending into one are one outcome. `MenuView` renders the header above its scrolling viewport and moves `role="listbox"` onto that viewport, because a breadcrumb is not an option and a listbox may not carry one. The zh composer placeholder says `文件或对话`, matching the `对话` section title the same menu already shows. ## Alternatives considered -**Trust the projection cache outright: no cache row means no title.** Rejected by measurement and then by a test. Only 154 of 342 sessions in a real store carry a cache record, and 111 of those a title; the rest predate the cache or never checkpointed. Web e2e caught it immediately — a seeded cold session became unfindable by the title in its own log. +**Fold the missing titles from their logs, memoized per cold log.** Implemented first, then removed in review. It made the first filtered query over a corpus the cache had not covered read those logs — on a 342-session store, roughly 190 of them — to rescue sessions that predate the cache. Correlating that store against the cache's arrival showed why the trade is bad: every session the product writes today gets a checkpoint at creation, `turn/end`, and disposal, and an old session acquires one the first time it is opened. The gap is legacy data that heals on contact, not a shape discovery has to pay for on every keystroke. -**Fold the uncheckpointed titles only for the capped page.** Rejected: the filter runs before the page exists, so a title-substring query would skip exactly the sessions whose titles were deferred. The page-only fold survives for the empty-query path, where nothing is filtered. +**Read a cold session's title through `sessionQuery.observeSession` or `persistence.readFrom`.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read. **Debounce the candidate fetch.** Rejected. The reducer already resets every group to pending on each hit, so a trailing debounce extends the skeleton state and reads as *slower* while typing. With the fold removed, the round trip no longer justifies the timer; keeping the previous rows visible under a new generation is a separate decision with pick-safety consequences, and is not taken here. -**Read `.gitignore` to bound the index.** Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list covers the measured 41% and stays a config field. A workspace that keeps sources under one of those basenames must override `excludedDirectories`. +**Read `.gitignore` to bound the index.** Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list stays a config field a workspace overrides. + +**Exclude `lib` by default with the other build outputs.** Rejected: Ruby gems and many npm packages keep their sources there, and the miss would be silent and total rather than the partial truncation this change removes. This repository builds into `lib` and adds it through `excludedDirectories`; the shipped default names only outputs no ecosystem also uses for sources. **Read the session's last activity on the host, from the `sessionListMetadata` projection.** Rejected: that projection key is declared by `api-session-controller`, so reading it would make a `packages/context` capability depend on the BFF assembly — a direction with no precedent in this repository. The client already holds the same number in `ctx.sessions.list`, which is also what makes the two surfaces agree by construction rather than by coincidence. @@ -48,7 +50,9 @@ The zh composer placeholder says `文件或对话`, matching the `对话` sectio ## Consequences -A deployment without `session-projection-cache` composed keeps the old cost and the old head-only fold. With it composed, a first query over a corpus the cache has not covered still reads those logs once; the memo makes that a per-log cost rather than a per-keystroke one. A dedicated title index would remove the remainder, and the session-reference README now names that as the open path. +A deployment without `session-projection-cache` composed labels every cold session by its id; without `session-projections` too, every session. Discovery is as complete as the projections it reads, and never slower than them. + +A store carrying sessions from before the cache shipped shows those sessions by id until each is opened once. On the machine this change was measured against that is roughly 190 of 342 — visible to a long-time user, invisible to a new one, and shrinking with use. The file index is one invalidation stale: a bare query answered immediately after a tool result reflects the tree as of the previous traversal, and the following query sees the rebuild. Sources kept under an excluded basename need an `excludedDirectories` override. @@ -58,6 +62,6 @@ The reference row content is now derived from what the neighbouring chrome alrea ## Testing -Package tests cover the checkpoint path (a filtered query that reads no log), the memoized cold fold and its identity invalidation, the uncheckpointed-tail fold that keeps title filtering complete, stale-while-revalidate including a failed refresh, and the breadcrumb contract from both ends. `reference-composer.e2e.ts` covers the shipped composition: the refreshed menu golden shows the trimmed rows, and a new case drills into a folder, asserts the breadcrumb appears only then, and clicks the root crumb back to a bare `@`. +Package tests cover a renamed attached session found by its new title while its checkpoint still holds the old one, a cold session labeled from its checkpoint, an unprojected session labeled by its id, a composition with no projection face at all, `readTitleSnapshots` never called on any of those paths, stale-while-revalidate driven through the real filesystem — a root that vanishes under a live index keeps answering and picks the workspace back up when it returns — an unreadable subtree costing only its own candidates, a `lib` tree that stays searchable, and the breadcrumb contract from both ends including a refused drill edit. `reference-composer.e2e.ts` covers the shipped composition: the refreshed menu golden shows the trimmed rows, and a new case drills into a folder, asserts the breadcrumb appears only then, and clicks the root crumb back to a bare `@`. Its seeded sessions appear there as ids, because a seed reaches disk as a log alone and this scaffold seeds after the host has already loaded its projection-cache table; seeding before boot would give the app a populated session list at startup, which the fresh-workspace flow four scenarios share does not expect. The titled paths stay in the package suite, and the e2e asserts the id labels it actually produces rather than a title the fixture cannot carry. The 1139 ms figure is a measured floor for the server-side I/O against a real store, not an instrumented end-to-end UI latency; the web e2e scaffold's isolated `DSH_HOME` cannot reproduce the corpus that produces it. diff --git a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md index 0a26f5ddc2..8569473d1d 100644 --- a/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md +++ b/.agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.zh.md @@ -18,27 +18,29 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 ## Decision -**发现成本取决于投影缓存的覆盖率。** `SessionReferenceResolver` 用 `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])` 标注候选,这是一次同步内存读,`api-session.list` 已经在用。缓存已建立 checkpoint 的会话完全不需要读日志。其余会话各折叠一次并按进程生命周期记在 resolver 上,以该 header 的创建事实为键,因此被复用的 id 不会继承过期标题;重新挂载的会话永不被记住,因为它的日志仍在增长。非空查询在过滤之前折叠尚未 checkpoint 的剩余部分,因为过滤读取的正是标签——把这次折叠推迟到截断后的页面,会让一个会话按它自己的标题搜不到。空查询不做过滤,因此它未解析的尾部留给页面处理。 +**发现用的标签只来自投影读,绝不读日志。** `SessionReferenceResolver` 向每个被列出的会话的投影索取标题,无人作答就用它的 id。是否挂载由会话存储在读取时决定,而不是由产生该记录的那次列举决定,因此在两者之间挂载上来的会话绝不会被一份其实时日志已经越过的 checkpoint 作答。已挂载的会话由 `ctx.sessionProjections.snapshot(session, ['title'])` 作答——那是随每个已提交事件推进的实时切面,事件本就在内存里。冷会话由 `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])` 作答,即它转冷时写下的持久化 checkpoint。两者都是同步的,都不碰日志。 -缓存是可选的;未组合缓存时,先前的折叠路径原样保留,包括「未经过滤的列表只折叠按 cwd 排序的头部」这一限制。 +从日志折叠一个标题的代价是整份日志,而这次调用位于 `@` 补全每一次击键之下,所以干脆不做。没有任何投影能作答的会话——早于缓存组合存在的、或被直接 seed 到磁盘的——用 id 作标签,且无法按标题搜到。这个状态会自愈:把该会话打开一次即挂载,销毁时就写下 checkpoint。 -**失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十六个——版本控制与依赖目录,加上本 harness 运行的各生态的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories` 与 `maxEntries` 配置字段。 +**失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。根目录不可读的遍历会失败而不是落定:不可读的分支只损失它自己的候选,而不可读的根意味着这次遍历什么都没学到,把它作为空索引发布会覆盖掉仍然有效的条目,且不留下任何可供重试的失效标记。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十五个——版本控制与依赖目录,加上没有任何生态用作源码目录的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories` 与 `maxEntries` 配置字段。 **每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算,因为排序时它本就同时握有两个工作目录——并用宿主会话列表的 `updatedAt` 经该列表所用的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。列表中没有的会话回落到候选自带的 `createdAt`。`relativeTime` 从 `ui-workspace` 的 `tree.ts` 移到 `ui-primitives`;按 locale-owned 文案的规则,词句仍留在各插件自己的字典里。session id 离开行内:它本就是无标题会话回落到的标签。 -**下钻会发布面包屑,键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑,在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问,后者说明该查询由下钻还是键入产生。`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store,冻结的菜单归约器因此对它一无所知;点击面包屑经 `onPick` 以 `action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上,并把 `role="listbox"` 移到该视口上,因为面包屑不是选项,listbox 也不得承载它。 +**下钻会发布面包屑,键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑,在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问,该标记只在下钻的编辑真正落到草稿上时才置位——被拒绝的编辑保持清零,因此头部绝不会指向没人进去过的目录——并在菜单关闭前跨越后续键入。`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store,冻结的菜单归约器因此对它一无所知;点击面包屑经 `onPick` 以 `action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上,并把 `role="listbox"` 移到该视口上,因为面包屑不是选项,listbox 也不得承载它。 中文 composer placeholder 改为 `文件或对话`,与同一个菜单已经显示的 `对话` 分组标题一致。 ## Alternatives considered -**彻底信任投影缓存:没有缓存行就没有标题。** 先被实测否决,再被测试否决。真实存储里 342 个会话只有 154 个带缓存记录,其中 111 个带标题;其余早于缓存存在或从未 checkpoint。Web e2e 当场抓到——一个被 seed 的冷会话按它自己日志里的标题搜不到了。 +**从日志折叠缺失的标题,并按冷日志记忆化。** 先实现了,评审时移除。它会让缓存尚未覆盖的语料在首次过滤查询时读那些日志——在 342 会话的存储上约 190 份——只为救回早于缓存存在的会话。把该存储与缓存的上线时间对照后可以看出这笔买卖不划算:今天产品写出的每个会话都会在创建、`turn/end` 与销毁三处建立 checkpoint,而旧会话只要被打开一次就会补上。缺口是「一碰即愈」的存量数据,不是发现路径每次击键都该付的形状。 -**只为截断后的页面折叠未 checkpoint 的标题。** 否决:过滤发生在页面存在之前,因此按标题子串查询恰好会跳过那些被推迟折叠的会话。仅页面折叠这一形态保留给空查询路径,那里不做过滤。 +**通过 `sessionQuery.observeSession` 或 `persistence.readFrom` 读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读。 **给候选拉取加防抖。** 否决。归约器在每次命中时已经把所有分组重置为 pending,因此尾部防抖会延长骨架状态,输入时读起来更慢。折叠成本移除后,往返时间不再值得一个定时器;在新 generation 下保留上一批行是另一个决定,带有误选后果,此处不做。 -**读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表覆盖了实测的 41%,且本就是配置字段。把源码放在其中某个基名下的工作区需覆盖 `excludedDirectories`。 +**读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表本就是工作区可覆盖的配置字段。 + +**把 `lib` 和其余构建产物一起放进默认排除。** 否决:Ruby gem 与相当一部分 npm 包的源码就在那里,而这次缺失会是无声且彻底的,比本次改动所消除的部分截断更糟。本仓库构建进 `lib`,通过 `excludedDirectories` 自行加上;随附默认值只列没有任何生态用作源码目录的产物名。 **在宿主侧从 `sessionListMetadata` 投影读取会话最近活动时间。** 否决:该投影键由 `api-session-controller` 声明,读取它会让 `packages/context` 的能力依赖 BFF 装配层——本仓库没有这个方向的先例。客户端的 `ctx.sessions.list` 里本就有同一个数字,而这也正是让两处界面「由构造而非由巧合」保持一致的原因。 @@ -48,7 +50,9 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 ## Consequences -未组合 `session-projection-cache` 的部署保持原有成本与原有的仅头部折叠。组合之后,对缓存尚未覆盖的语料,首次查询仍会读一次那些日志;记忆化把它变成按日志一次而不是按击键一次的成本。专用标题索引可以消除剩余部分,session-reference README 现在把它记为开放路径。 +未组合 `session-projection-cache` 的部署把每个冷会话都标成 id;连 `session-projections` 也没有时,所有会话都是 id。发现能力与它所读的投影一样完整,且绝不会比投影更慢。 + +存有「缓存上线之前的会话」的存储,会把那些会话显示成 id,直到各自被打开一次。在本次实测的机器上约为 342 个里的 190 个——老用户看得见,新用户看不见,且随使用递减。 文件索引落后一次失效:紧接工具结果之后的裸查询反映的是上一次遍历时的目录树,下一次查询才看到重建结果。把源码放在被排除基名下的工作区需要覆盖 `excludedDirectories`。 @@ -58,6 +62,6 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的 ## Testing -包级测试覆盖 checkpoint 路径(一次不读任何日志的过滤查询)、记忆化冷折叠及其身份失效、保证标题过滤完整的未 checkpoint 尾部折叠、含刷新失败在内的 stale-while-revalidate,以及面包屑契约的两端。`reference-composer.e2e.ts` 覆盖随附组合:刷新后的菜单 golden 显示精简后的行,新增用例下钻进入文件夹、断言面包屑只在此时出现、并点击根节点回到裸 `@`。 +包级测试覆盖:被改名的挂载会话在 checkpoint 仍是旧值时按新标题被搜到、冷会话由 checkpoint 标注、无投影可答的会话标成 id、完全没有投影面的组合、以上路径均未调用 `readTitleSnapshots`、经真实文件系统驱动的 stale-while-revalidate——根目录在活索引之下消失时仍继续作答,并在它回来后自动接上——不可读子目录只损失自身候选、`lib` 目录仍可搜索,以及面包屑契约的两端(含被拒绝的下钻编辑)。`reference-composer.e2e.ts` 覆盖随附组合:刷新后的菜单 golden 显示精简后的行,新增用例下钻进入文件夹、断言面包屑只在此时出现、并点击根节点回到裸 `@`。其中被 seed 的会话在那里显示为 id,因为 seed 落到磁盘的只有日志,而该 scaffold 在宿主已载入投影缓存表之后才 seed;把 seed 提前到 boot 之前会让应用启动时就带着一份会话列表,而四个场景共用的「连接新工作区」流程并不预期这一点。带标题的路径留在包级测试里,e2e 断言它真正产生的 id 标签,而不是这个 fixture 承载不了的标题。 1139 ms 是针对真实存储的服务端 I/O 实测下限,不是插桩得到的端到端 UI 延迟;web e2e scaffold 的隔离 `DSH_HOME` 无法复现产生该数字的语料。 diff --git a/apps/web/tests/expected/reference-composer/menu.expected.md b/apps/web/tests/expected/reference-composer/menu.expected.md index 792c679ed6..ea65a91653 100644 --- a/apps/web/tests/expected/reference-composer/menu.expected.md +++ b/apps/web/tests/expected/reference-composer/menu.expected.md @@ -6,5 +6,5 @@ - img - option "reference.txt" - text: Sessions - - option "Reference order target {{cwd}} · {{age}}" - - option "Research notes {{cwd}} · {{age}}" + - option "reference-order-target-session {{cwd}} · {{age}}" + - option "reference-source-session {{cwd}} · {{age}}" diff --git a/apps/web/tests/reference-composer.e2e.ts b/apps/web/tests/reference-composer.e2e.ts index ed4e0ed4aa..ad7e5410da 100644 --- a/apps/web/tests/reference-composer.e2e.ts +++ b/apps/web/tests/reference-composer.e2e.ts @@ -157,7 +157,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through expect(snapshot).toContain('Sessions') expect(snapshot).not.toContain('text: reference Files & folders') expect(snapshot).toContain('reference.txt') - expect(snapshot).toContain('Research notes') + // A seed reaches disk as a log alone, and the Host labels a session from + // its projections: no checkpoint, so the row is its id. The fixture's own + // title (`Research notes`) is unreachable here by construction, and the + // package suite owns the titled paths. + expect(snapshot).toContain(SOURCE_SESSION_ID) + expect(snapshot).not.toContain('Research notes') expect(snapshot).not.toContain('text: Subagents') await input.fill('@reference') @@ -170,12 +175,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await expect.poll(() => fileReference.locator('svg').count()).toBe(1) await expect.poll(() => input.textContent()).toBe('reference.txt ') - await input.fill('@Research') - await menu.getByRole('option', { name: /Research notes/ }).click() + await input.fill('@reference-source') + await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click() const sessionReference = page.locator('[data-composer-chip]').last() - await expect.poll(() => sessionReference.textContent()).toBe('Research notes') + await expect.poll(() => sessionReference.textContent()).toBe(SOURCE_SESSION_ID) await expect.poll(() => sessionReference.locator('svg').count()).toBe(1) - await expect.poll(() => input.textContent()).toBe('Research notes ') + await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} `) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) @@ -195,16 +200,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through await input.click() await page.keyboard.press('ControlOrMeta+A') await page.keyboard.press('ArrowLeft') - await page.keyboard.type('@Research') - await menu.getByRole('option', { name: /Research notes/ }).click() + await page.keyboard.type('@reference-source') + await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click() // Both chips survive the boundary insert: the session chip lands ahead of // the intact file chip. const chips = input.locator('[data-composer-chip]') await expect.poll(() => chips.count()).toBe(2) - await expect.poll(() => chips.first().textContent()).toBe('Research notes') + await expect.poll(() => chips.first().textContent()).toBe(SOURCE_SESSION_ID) await expect.poll(() => chips.last().textContent()).toBe('reference.txt') - await expect.poll(() => input.textContent()).toBe('Research notes reference.txt ') + await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} reference.txt `) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index c75b32c406..9f48576308 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md -session-reference.md: 66e107266e15a2951020c3152868fc6f5937738c -session-reference.zh.md: 75c98fe8afe5b60e570385583e3520bf3646dfe2 +session-reference.md: 429459f37132a379d18251af646181bc29d97d40 +session-reference.zh.md: b505498e560c2c5b8f211be7650cc39a16b578c3 diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 66e107266e..429459f371 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -145,11 +145,9 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * - * A title comes from the projection cache when that cache holds a - * checkpoint for the session; otherwise it is folded from the session's log - * once and remembered for as long as the log stays cold. Without the cache - * composed, only the cwd-ranked head of an unfiltered listing is folded, so - * its tail cannot match a title substring. + * Discovery runs at keystroke rate, so a title only ever comes from a + * projection read: see {@link SessionReferenceResolver.projectedTitle} for + * which sessions can answer one and which fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index 75c98fe8af..b505498e56 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -145,11 +145,9 @@ Exact-read consumer that prepares immutable cross-session message context. /** * List reference candidates, ranked by working-directory affinity. * - * A title comes from the projection cache when that cache holds a - * checkpoint for the session; otherwise it is folded from the session's log - * once and remembered for as long as the log stays cold. Without the cache - * composed, only the cwd-ranked head of an unfiltered listing is folded, so - * its tail cannot match a title substring. + * Discovery runs at keystroke rate, so a title only ever comes from a + * projection read: see {@link SessionReferenceResolver.projectedTitle} for + * which sessions can answer one and which fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. diff --git a/packages/client/ui-input-trigger/src/client/controller.ts b/packages/client/ui-input-trigger/src/client/controller.ts index 7808f32589..5d3e1bf0b3 100644 --- a/packages/client/ui-input-trigger/src/client/controller.ts +++ b/packages/client/ui-input-trigger/src/client/controller.ts @@ -485,10 +485,13 @@ export class InputTriggerController { }) this.stopFetch() this.reduce({ type: 'close' }) - // After the close above, so the reducer's own teardown cannot clear it: - // the drilled query arrives on the next track() call. - this.drilled = action === 'drill' - this.execute(outcome, hit.span) + const applied = this.execute(outcome, hit.span) + // Set after the close above, so the reducer's own teardown cannot clear + // it, and only when the descent text actually landed: a refused edit + // (stale draft revision, or no listener) leaves the draft where it was, + // and a header over that draft would name a directory nobody descended + // into while hiding the locations its rows still need. + this.drilled = action === 'drill' && applied } /** Re-poll every header-bearing source in the hit roster and publish their crumbs. */ diff --git a/packages/client/ui-input-trigger/src/types.ts b/packages/client/ui-input-trigger/src/types.ts index dd5539e0cb..bd862a71a1 100644 --- a/packages/client/ui-input-trigger/src/types.ts +++ b/packages/client/ui-input-trigger/src/types.ts @@ -81,9 +81,10 @@ export interface HeaderRequest { /** Whether the active @file token is an open quoted path. */ readonly quoted?: boolean /** - * True while the open menu was reached by a drill pick rather than typed. - * The pipeline owns this fact; what it means for a header is the source's - * to decide. + * True while this menu was opened or last re-scoped by a drill pick. It + * survives further typing and clears when the menu closes, so a query typed + * after a drill still reads as drilled. The pipeline owns the fact; what it + * means for a header is the source's to decide. */ readonly drilled: boolean } @@ -104,7 +105,7 @@ export interface CandidateRequest { /** Whether the active @file token is an open quoted path. */ readonly quoted?: boolean readonly position: TriggerPosition - /** Whether the open menu was reached by a drill pick rather than typed. */ + /** Whether this menu was opened or last re-scoped by a drill pick; see {@link HeaderRequest.drilled}. */ readonly drilled: boolean readonly signal: AbortSignal } diff --git a/packages/client/ui-input-trigger/tests/service.client.spec.ts b/packages/client/ui-input-trigger/tests/service.client.spec.ts index 157c6c4a6e..e63a736694 100644 --- a/packages/client/ui-input-trigger/tests/service.client.spec.ts +++ b/packages/client/ui-input-trigger/tests/service.client.spec.ts @@ -626,7 +626,8 @@ describe('header / drilled descent', () => { it('routes a crumb through the source drill path and refuses the current step', async () => { const { source, picks } = crumbSource() - const { controller } = controllerBench([source]) + const { controller, actx } = controllerBench([source]) + actx.on('slash/input-insert-text', () => true) controller.track('@sr', 3, { tier: 'plain' }, 1) await tick() controller.pick('reference', 0, 'drill') @@ -640,6 +641,18 @@ describe('header / drilled descent', () => { expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' }) }) + it('publishes no crumbs when the input refused the drill edit', async () => { + const { source } = crumbSource() + const { controller } = controllerBench([source]) + // No listener accepts the insert, so the descent text never landed. + controller.track('@sr', 3, { tier: 'plain' }, 1) + await tick() + controller.pick('reference', 0, 'drill') + controller.track('@src/', 5, { tier: 'plain' }, 2) + await tick() + expect(controller.headers.getSnapshot().size).toBe(0) + }) + it('drops a source whose header throws and keeps the rest of the menu', async () => { const failing: InputTriggerSource = { trigger: '@', diff --git a/packages/context/file-reference-local/README.i18n.yaml b/packages/context/file-reference-local/README.i18n.yaml index b665089a8f..15eb840259 100644 --- a/packages/context/file-reference-local/README.i18n.yaml +++ b/packages/context/file-reference-local/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/context/file-reference-local/README.md -README.md: 7a0fff671f7ecc8b48b16e86ea71a3c0071a0e38 -README.zh.md: 5b01e7c9b10676f89aecdc6a74c5ea5b8b155f3e +README.md: da5db0882e9f4538bdd901a970eca98aac10bbde +README.zh.md: cea38d335c8b29ddc3c875782cb62df1d0ae6e0d diff --git a/packages/context/file-reference-local/README.md b/packages/context/file-reference-local/README.md index 7a0fff671f..da5db0882e 100644 --- a/packages/context/file-reference-local/README.md +++ b/packages/context/file-reference-local/README.md @@ -47,7 +47,7 @@ Typing `@` in a host UI returns up to `maxResults` ranked path candidates for th |---|---|---| | `maxResults` | `20` | Maximum ranked candidates returned for one query | | `maxEntries` | `50000` | Maximum files and directories indexed per agent workspace | -| `excludedDirectories` | `['.git', 'node_modules', 'lib', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates | +| `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates | Every numeric value must be a positive safe integer, and every excluded name must be a non-empty basename without `/` or `\`. @@ -75,7 +75,7 @@ The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at t ### Main flow -A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree; unreadable or excluded subtrees contribute no candidates. +A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree. An unreadable or excluded subtree contributes no candidates, while an unreadable root fails its traversal instead: a transient failure must not replace still-good entries with an empty index. @@ -124,7 +124,7 @@ The stable sentence joins the system-prompt prefix. Mounting or removing this pr These limits define when the provider is a poor fit. They are current package constraints. - **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool. -- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name build outputs by convention, so a workspace that keeps sources under one of those basenames must override `excludedDirectories`. +- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name only build outputs no ecosystem also uses for sources; `lib` is deliberately absent, so a workspace that builds into it adds that name through `excludedDirectories`. - **One invalidation of staleness** — a bare query answered right after a tool result reflects the tree as of the previous traversal; the following query sees the rebuild. - **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded. diff --git a/packages/context/file-reference-local/README.zh.md b/packages/context/file-reference-local/README.zh.md index 5b01e7c9b1..cea38d335c 100644 --- a/packages/context/file-reference-local/README.zh.md +++ b/packages/context/file-reference-local/README.zh.md @@ -47,7 +47,7 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选 |---|---|---| | `maxResults` | `20` | 单次查询返回的排序候选最大数量 | | `maxEntries` | `50000` | 每个 agent 工作区建立索引的文件与目录最大数量 | -| `excludedDirectories` | `['.git', 'node_modules', 'lib', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 | +| `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 | 所有数值都必须是正的安全整数,所有排除名都必须是不含 `/` 或 `\` 的非空基名。 @@ -75,7 +75,7 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选 ### 主要流程 -`list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树;不可读或已排除的子目录不贡献候选。 +`list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树。不可读或已排除的子目录不贡献候选,而不可读的根目录则让该次遍历失败:一次瞬时故障不得用空索引覆盖仍然有效的条目。 @@ -124,7 +124,7 @@ Tokens prefixed with @ are workspace paths the user explicitly referenced, relat 这些限制说明该提供方何时不合适。它们是当前包约束。 - **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。 -- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项按惯例命名构建产物,若工作区把源码放在其中某个基名下,需覆盖 `excludedDirectories`。 +- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项只列没有任何生态用作源码目录的构建产物;`lib` 被刻意排除在外,因此构建进 `lib` 的工作区需通过 `excludedDirectories` 自行加上。 - **一次失效的陈旧窗口**:紧接工具结果之后的模糊查询反映的是上一次遍历时的目录树;下一次查询才看到重建结果。 - **没有忽略文件语义**:`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。 diff --git a/packages/context/file-reference-local/src/search.ts b/packages/context/file-reference-local/src/search.ts index c797b7b737..ba5d30c2f3 100644 --- a/packages/context/file-reference-local/src/search.ts +++ b/packages/context/file-reference-local/src/search.ts @@ -18,15 +18,19 @@ export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20 export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 50_000 /** * Directory basenames omitted from traversal unless the deployment overrides - * them: version-control and dependency stores plus the build outputs of the - * ecosystems this harness runs in. Generated files carry the basenames of the + * them: version-control and dependency stores plus build-output names that no + * ecosystem also uses for sources. Generated files carry the basenames of the * sources that produced them, so an unfiltered tree both spends the entry - * budget twice and ranks `lib/x.js` beside `src/x.ts` for every query. + * budget twice and ranks `dist/x.js` beside `src/x.ts` for every query. + * + * `lib` is deliberately absent: Ruby gems and many npm packages keep their + * sources there, and excluding it would make `@` miss those sources entirely + * and silently. A workspace that builds into `lib` adds it through + * `excludedDirectories`. */ export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [ '.git', 'node_modules', - 'lib', 'dist', 'build', 'out', @@ -204,7 +208,13 @@ export class WorkspaceFileSearch { if (directory === undefined) { throw new Error('file search selected a missing directory') } - const entries = await readDirectory(directory.absolute, signal) + // The root is not a subtree: an unreadable branch costs its own + // candidates, but an unreadable root means the traversal learned + // nothing. Letting that settle would publish an empty index over + // entries that are still good and leave no invalidation to retry from. + const entries = cursor === 0 + ? await readWorkspaceRoot(directory.absolute, signal) + : await readDirectory(directory.absolute, signal) for (const entry of entries) { signal.throwIfAborted() const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}` @@ -271,6 +281,13 @@ async function resolveDisplayDirectory( return absolute } +async function readWorkspaceRoot(absolute: string, signal: AbortSignal) { + signal.throwIfAborted() + const entries = await readdir(absolute, { withFileTypes: true }) + signal.throwIfAborted() + return entries.sort((left, right) => compareText(left.name, right.name)) +} + async function readDirectory(absolute: string, signal: AbortSignal) { signal.throwIfAborted() try { diff --git a/packages/context/file-reference-local/tests/search.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts index e9cae870ec..7b48148bf3 100644 --- a/packages/context/file-reference-local/tests/search.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -11,6 +11,8 @@ import { const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] +/** Permission-stripped directories; restored before cleanup can remove them. */ +const locks: string[] = [] async function workspace(): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-')) @@ -45,6 +47,7 @@ function search(root: string, overrides: Partial { + for (const locked of locks.splice(0)) await chmod(locked, 0o700).catch(() => undefined) for (const instance of searches.splice(0)) instance.dispose() await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) @@ -172,36 +175,45 @@ describe('WorkspaceFileSearch', () => { files.dispose() }) - it('keeps the stale entries when a refresh fails and retries on the next query', async () => { + it('keeps the stale entries when the workspace root is unreadable, and retries once it returns', async () => { const root = await workspace() const files = search(root) const signal = new AbortController().signal expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + + // A root that vanishes under a live index: an unreadable branch costs its + // own candidates, but an unreadable root must not be published as an + // empty workspace over entries that are still good. + await rm(root, { recursive: true, force: true }) files.invalidate() - const scan = vi - .spyOn(files as unknown as { scanWorkspace: () => Promise }, 'scanWorkspace') - .mockRejectedValueOnce(new Error('scan failed')) expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) - await vi.waitFor(() => { expect(scan).toHaveBeenCalledTimes(1) }) - scan.mockRestore() - // The failed attempt left the index stale, so the next query starts a new one. - await writeFile(join(root, 'retried.ts'), 'retried') - expect(await files.list('retried', signal)).toEqual([]) + await new Promise((resolve) => { setTimeout(resolve, 50) }) + expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + + // The failed attempt left the index stale, so its return is picked up + // without waiting for another invalidation. + await mkdir(root, { recursive: true }) + await writeFile(join(root, 'restored.ts'), 'restored') await vi.waitFor(async () => { - expect(await files.list('retried', signal)).toEqual([{ path: 'retried.ts', kind: 'file' }]) + expect(await files.list('restored', signal)).toEqual([{ path: 'restored.ts', kind: 'file' }]) }) }) - it('keeps nothing from a traversal that settles after disposal', async () => { + it('lets an unreadable subtree cost only its own candidates', async () => { const root = await workspace() + const locked = join(root, 'locked') + await mkdir(locked, { recursive: true }) + await writeFile(join(locked, 'sealed.ts'), 'sealed') + await chmod(locked, 0o000) + locks.push(locked) const files = search(root) - const pending = files.list('README', new AbortController().signal) - files.dispose() - await expect(pending).rejects.toThrow('file search index disposed') - // The in-flight traversal still settles; its entries must reach no caller. - await vi.waitFor(async () => { - expect(await files.list('README', new AbortController().signal)).toEqual([]) - }) + const signal = new AbortController().signal + + // The branch itself yields nothing, and the rest of the tree still does. + expect(await files.list('sealed', signal)).toEqual([]) + expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + // The directory is still offered: only reading through it fails. + expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }]) }) it('enforces the entry cap', async () => { @@ -214,13 +226,23 @@ describe('WorkspaceFileSearch', () => { it('never traverses an excluded build output, so generated twins cannot outrank sources', async () => { const root = await workspace() - await mkdir(join(root, 'lib'), { recursive: true }) - await writeFile(join(root, 'lib', 'terminal-view.js'), 'built') + await mkdir(join(root, 'dist'), { recursive: true }) + await writeFile(join(root, 'dist', 'terminal-view.js'), 'built') const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] }) expect(await files.list('terminal-view', new AbortController().signal)).toEqual([ { path: 'src/terminal-view.ts', kind: 'file' }, ]) - expect(await files.list('lib/', new AbortController().signal)).toEqual([]) + expect(await files.list('dist/', new AbortController().signal)).toEqual([]) + }) + + it('still offers a `lib` tree, where several ecosystems keep their sources', async () => { + const root = await workspace() + await mkdir(join(root, 'lib'), { recursive: true }) + await writeFile(join(root, 'lib', 'gem-entry.rb'), 'source') + const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] }) + expect(await files.list('gem-entry', new AbortController().signal)).toEqual([ + { path: 'lib/gem-entry.rb', kind: 'file' }, + ]) }) it('cancels individual callers, skips missing directories, and validates limits', async () => { diff --git a/packages/context/session-reference/README.i18n.yaml b/packages/context/session-reference/README.i18n.yaml index d4b4fd1711..af85ae374c 100644 --- a/packages/context/session-reference/README.i18n.yaml +++ b/packages/context/session-reference/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/context/session-reference/README.md -README.md: f88e1aacd7f6c83e25f90165f49dae183d0a94d5 -README.zh.md: c432abc8a551c3d9982c620d7c16d9d58e92611e +README.md: 804cfea6357d4e9fc202e6562e75d740c5702c24 +README.zh.md: 4a249ea63cb7546603666990f67add54ebfe035a diff --git a/packages/context/session-reference/README.md b/packages/context/session-reference/README.md index f88e1aacd7..804cfea635 100644 --- a/packages/context/session-reference/README.md +++ b/packages/context/session-reference/README.md @@ -37,7 +37,7 @@ A message that cites other sessions is followed immediately by a `## Referenced ### Finding sessions to reference -`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the latest log-backed title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. +`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the projected title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention. ### Configuration @@ -121,7 +121,7 @@ The request and snapshot are consecutive append-only target messages and preserv These limits define when cross-session references are a poor fit. They are current package constraints. - **No body discovery** — candidate queries inspect titles but do not search message bodies. -- **Discovery cost tracks projection-cache coverage** — a session the projection cache has checkpointed costs no log read at all. Every other session is folded from its log once and remembered while that log stays cold, so a first query over a corpus the cache has not covered still reads those logs; a dedicated title index may replace that path without changing URI, snapshot, or persistence contracts. Without the cache composed, an unfiltered listing folds only its cwd-ranked head, so its tail cannot match a title substring. +- **Labels come from projections alone** — an attached session is labeled from its live projection cut, a cold one from its durable checkpoint, and a session neither answers for is labeled by its id and cannot be found by its title. Discovery never reads a log: folding one title costs a whole log, and this runs under every completion keystroke. A session persisted before the projection cache was composed regains its title the first time it is opened, which checkpoints it. - **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool. - **Text projection only** — non-text user and assistant blocks are not propagated across sessions. - **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations. diff --git a/packages/context/session-reference/README.zh.md b/packages/context/session-reference/README.zh.md index c432abc8a5..4a249ea63c 100644 --- a/packages/context/session-reference/README.zh.md +++ b/packages/context/session-reference/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" ### 查找可引用的会话 -`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或最新日志标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 +`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或投影标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。 ### 配置 @@ -121,7 +121,7 @@ kind: "package-reference" 这些限制说明跨会话引用何时不合适。它们是当前包约束。 - **不支持消息正文检索**:候选查询会检查标题,但不搜索消息主体。 -- **发现成本取决于投影缓存的覆盖率**:投影缓存已建立 checkpoint 的会话完全不需要读日志。其余会话各自从日志折叠一次,并在该日志保持冷态期间被记住;因此对缓存尚未覆盖的语料,首次查询仍会读取那些日志——专用标题索引未来可以替换这条路径,而不改变 URI、快照或持久化约定。未组合缓存时,未经过滤的列表只折叠按 cwd 排序的头部,其尾部因此无法命中标题子串。 +- **标签只来自投影**:已挂载的会话由实时投影切面标注,冷会话由持久化 checkpoint 标注,两者都答不上来的会话用 id 作标签且无法按标题搜到。发现路径绝不读日志:折叠一个标题的代价是整份日志,而这段代码位于补全的每一次击键之下。早于投影缓存组合存在的会话,只要被打开一次(销毁时即写 checkpoint)就会恢复标题。 - **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。 - **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。 - **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。 diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 861d93f086..14ce656f94 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -11,15 +11,13 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -// Type-only: the `title` projection key and the cache's Context merge, so -// discovery can label a cold session without reading its log. +import type { SessionId } from '@deepseek-ai/dsh-session' +// Type-only: the `title` projection key plus the live registry and durable +// cache Context merges — the two projection faces discovery labels from. import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' import type {} from '@deepseek-ai/dsh-session-title' -import type { - SessionRecord, SessionSurfaceSnapshot, SessionTitleObservationResult, -} from '@deepseek-ai/dsh-session-query' +import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query' import { DEFAULT_CANDIDATE_LIMIT, DEFAULT_MAX_REFERENCE_BYTES, @@ -78,25 +76,6 @@ interface RenderedSource { stats: ReferenceRetentionStats } -/** One listed session, its listing position (the stable rank tiebreak), and its resolved title. */ -interface LabelledSession { - record: SessionRecord - index: number - label: string - /** - * No checkpoint answered for this session, so `label` is the id placeholder - * and only a log fold can improve it. - */ - unresolved?: boolean -} - -/** One folded cold title, pinned to the log identity that produced it. */ -interface FoldedTitle { - /** Creation facts of the folded header: a reused id with different ones is a different log. */ - identity: string - title: string | undefined -} - /** Exact-read consumer that prepares immutable cross-session message context. */ export class SessionReferenceResolver extends TypertRemoteService { static inject = ['sessionQuery'] @@ -107,12 +86,6 @@ export class SessionReferenceResolver extends TypertRemoteService { }) private readonly config: Required - /** - * Cold-log title folds, kept for the process lifetime. A log that no - * session is attached to never grows, so one fold answers every later - * keystroke instead of re-reading the log per query. - */ - private readonly foldedTitles = new Map() constructor(ctx: Context, config: Config = {}) { super(ctx, 'sessionReferenceResolver') @@ -182,11 +155,9 @@ export class SessionReferenceResolver extends TypertRemoteService { /** * List reference candidates, ranked by working-directory affinity. * - * A title comes from the projection cache when that cache holds a - * checkpoint for the session; otherwise it is folded from the session's log - * once and remembered for as long as the log stays cold. Without the cache - * composed, only the cwd-ranked head of an unfiltered listing is folded, so - * its tail cannot match a title substring. + * Discovery runs at keystroke rate, so a title only ever comes from a + * projection read: see {@link SessionReferenceResolver.projectedTitle} for + * which sessions can answer one and which fall back to their id. * @param agent - target agent; self is excluded and its cwd drives ranking. * @param query - optional case-insensitive session-id/cwd/title substring. * @param limit - optional positive result cap. @@ -208,8 +179,12 @@ export class SessionReferenceResolver extends TypertRemoteService { const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal)) .filter(record => record.header.id !== agent.id) .map((record, index) => ({ record, index })) - const labelled = await this.labelCandidates(records, needle, limit, targetCwd, signal) - const page = labelled.filter(({ record, label }) => { + const labelled = records.map(({ record, index }) => ({ + record, + index, + label: this.projectedTitle(record) ?? record.header.id, + })) + return labelled.filter(({ record, label }) => { if (needle === '') return true return record.header.id.toLocaleLowerCase().includes(needle) || record.header.cwd?.toLocaleLowerCase().includes(needle) === true @@ -217,7 +192,6 @@ export class SessionReferenceResolver extends TypertRemoteService { }).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) || a.index - b.index) .slice(0, limit) - return (await this.foldUnresolved(page, signal)) .map(({ record, label }) => ({ sessionId: record.header.id, label, @@ -228,115 +202,34 @@ export class SessionReferenceResolver extends TypertRemoteService { } /** - * Resolve the title of every candidate the caller may filter. + * The title a session's projections can answer without reading its log. * - * With the projection cache composed it is the discovery index: titles come - * from its synchronous checkpoint rows, so a query filters the whole corpus - * without reading one log. Sessions the cache never checkpointed stay - * `unresolved` for {@link SessionReferenceResolver.resolvePageLabels} to - * fold. Without the cache, folding a title costs a full log read per - * session, so only the cwd-ranked head is inspected and an unlabeled tail - * cannot match a title substring. - * @param records - non-self session records in listing order. - * @param needle - lowercased query; empty means no title can change the result set. - * @param limit - caller result cap, applied here to the fold path's head. - * @param targetCwd - requesting agent's working directory (the ranking key). - * @param signal - caller cancellation. - * @returns labeled records for the caller to filter, rank, and cap. - */ - private async labelCandidates( - records: readonly { record: SessionRecord; index: number }[], - needle: string, - limit: number, - targetCwd: string | undefined, - signal: AbortSignal | undefined, - ): Promise { - const cache = this.ctx.get('sessionProjectionCache') - if (cache !== undefined) { - const labelled = records.map(({ record, index }) => { - const checkpoint = cachedTitle(cache.cachedSnapshot(record.header, ['title'])) - return { - record, - index, - label: checkpoint.title ?? record.header.id, - ...checkpoint.checkpointed ? {} : { unresolved: true }, - } - }) - // A filter reads every label, so a query cannot defer the sessions the - // cache never checkpointed to the page: fold them now. An empty query - // filters nothing, so its unresolved tail waits for the capped page. - return needle === '' ? labelled : this.foldUnresolved(labelled, signal) - } - const inspected = needle === '' - ? [...records] - .sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd) - || a.index - b.index) - .slice(0, limit) - : records - const observations = await settleWithCancellation( - this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal), - signal, - ) - return inspected.map(({ record, index }, observationIndex) => { - const observation = observations[observationIndex] as SessionTitleObservationResult - return { - record, - index, - label: observation.status === 'fulfilled' - ? observation.value.title?.title ?? record.header.id - : record.header.id, - } - }) - } - - /** - * Apply every title the projection cache could not answer. + * Attachment is decided by the store at read time, not by the listing: + * a session that attached in between would otherwise be answered from a + * checkpoint its live log has already moved past. * - * A session persisted before the cache existed, seeded straight to disk, or - * whose record was cleared carries a title only in its log, and reading one - * costs a whole log. Memoized folds carry the cost once per cold log rather - * than once per keystroke; a session that is attached again is never - * memoized, because its log is still being appended to. - * @param entries - labeled rows, some still carrying the id placeholder. - * @param signal - caller cancellation. - * @returns the same rows with every foldable title applied. + * An attached session answers from its live registry cut, which advances + * with every committed event, so a rename or a just-generated title is + * visible immediately; its events are already in memory, so the lazy fold + * costs no I/O. A cold session answers from the durable checkpoint the + * projection cache wrote when it went cold. + * + * Nothing else is attempted. Folding a title from a log costs the whole + * log, and this call sits under every keystroke of `@` completion. A + * session that no projection can answer for — one persisted before the + * cache was composed, or seeded straight to disk — is labeled by its id + * and cannot be found by its title until it is opened once, which + * checkpoints it. + * @param record - the listed session, live or cold. + * @returns the projected title, or undefined when no projection holds one. */ - private async foldUnresolved( - entries: readonly LabelledSession[], - signal: AbortSignal | undefined, - ): Promise { - const live = this.ctx.get('sessions') - const pending: SessionHeader[] = [] - const resolved = new Map() - for (const { record, unresolved } of entries) { - if (unresolved !== true) continue - const memo = record.live ? undefined : this.foldedTitles.get(record.header.id) - if (memo !== undefined && memo.identity === foldIdentity(record.header)) { - if (memo.title !== undefined) resolved.set(record.header.id, memo.title) - continue - } - pending.push(record.header) + private projectedTitle(record: SessionRecord): string | undefined { + const attached = this.ctx.get('sessions')?.get(record.header.id) + const projections = this.ctx.get('sessionProjections') + if (attached !== undefined && projections !== undefined) { + return titleOf(projections.snapshot(attached, ['title'])) } - if (pending.length > 0) { - const observations = await settleWithCancellation( - this.ctx.sessionQuery.readTitleSnapshots(pending.map(header => header.id), signal), - signal, - ) - pending.forEach((header, at) => { - const observation = observations[at] as SessionTitleObservationResult - if (observation.status !== 'fulfilled') return - const title = observation.value.title?.title - if (title !== undefined) resolved.set(header.id, title) - // An attached session's log is still growing, so its fold is a cut, - // not a fact to keep. - if (live?.get(header.id) === undefined) { - this.foldedTitles.set(header.id, { identity: foldIdentity(header), title }) - } - }) - } - return entries.map(entry => (entry.unresolved === true - ? { ...entry, label: resolved.get(entry.record.header.id) ?? entry.label } - : entry)) + return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title'])) } /** @@ -470,18 +363,10 @@ function renderPrompt(data: readonly ReferencedSessionData[]): string { return `${PROMPT_PREFIX}${stringifyTagSafeJson(data)}${PROMPT_SUFFIX}` } -/** The creation facts that make a header's log the same log the memo folded. */ -function foldIdentity(header: SessionHeader): string { - return `${String(header.createdAt)}:${header.cwd ?? ''}` -} - -/** Read one cached title row, separating "no checkpoint" from "checkpointed, still untitled". */ -function cachedTitle( - snapshot: ProjectionSnapshot | undefined, -): { checkpointed: boolean; title?: string } { +/** The title in one projection snapshot; undefined when the unit is absent or still untitled. */ +function titleOf(snapshot: ProjectionSnapshot | undefined): string | undefined { const title = snapshot?.values.title - if (title === undefined) return { checkpointed: false } - return title === null ? { checkpointed: true } : { checkpointed: true, title } + return title === undefined || title === null ? undefined : title } function candidateRank(candidateCwd: string | undefined, targetCwd: string | undefined): number { diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 202c04bf17..c8914d3695 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -4,7 +4,9 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction' import { createUserMessage, ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import SessionTitleService from '@deepseek-ai/dsh-session-title' import SessionReferenceResolver, { decodeSessionReferenceUri, encodeSessionReferenceUri, @@ -35,6 +37,11 @@ class TestSessionQueryEngine extends SessionQueryEngine { async function harness(config: Config = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) + // The live registry and the title unit it hosts: discovery labels an + // attached session from its projection cut, never from its log. + await ctx.plugin(SessionProjectionRegistry) + // Shipped base values: this suite only needs the unit the service registers. + await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) await ctx.plugin(TestSessionQueryEngine) await ctx.plugin(SessionReferenceResolver, config) return ctx @@ -296,141 +303,75 @@ describe('session reference discovery and preparation', () => { listSessions.mockRestore() }) - it('labels and filters the whole corpus from checkpoints, reading no log', async () => { + it('reads an attached session\'s current title, ahead of any checkpoint', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - for (const id of ['alpha', 'beta']) { - const created = ctx.sessions.create(SessionId(id), { meta: { cwd: '/same' } }) - created.append('session/title', { title: `${id} title`, messageSeqs: [], source: { kind: 'fallback' } }) - } - withProjectionCache(ctx, { alpha: 'Alpha checkpoint', beta: 'Beta checkpoint' }) + const live = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/same' } }) + live.append('session/title', { title: 'Old title', messageSeqs: [], source: { kind: 'fallback' } }) + // The durable checkpoint is write-behind, so it still holds the old value. + withProjectionCache(ctx, { live: 'Old title' }) + live.append('session/title', { title: 'Renamed mid turn', messageSeqs: [], source: { kind: 'user' } }) const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'alpha check')) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'renamed')) .resolves.toEqual([ - { sessionId: SessionId('alpha'), label: 'Alpha checkpoint', cwd: '/same', sameWorkspace: true, createdAt: expect.any(Number) as number }, + { sessionId: live.id, label: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt }, ]) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'old title')).resolves.toEqual([]) expect(readTitles).not.toHaveBeenCalled() readTitles.mockRestore() }) - it('folds a title the cache never checkpointed, for the shown page alone', async () => { - const ctx = await harness() - const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - const seeded = ctx.sessions.create(SessionId('seeded'), { meta: { cwd: '/same' } }) - seeded.append('session/title', { title: 'Seeded title', messageSeqs: [], source: { kind: 'fallback' } }) - const untitled = ctx.sessions.create(SessionId('untitled'), { meta: { cwd: '/same' } }) - // `untitled` is checkpointed with no title yet: nothing a log fold could add. - withProjectionCache(ctx, { untitled: null }) - const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') - - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: seeded.id, label: 'Seeded title', cwd: '/same', sameWorkspace: true, createdAt: seeded.header.createdAt }, - { sessionId: untitled.id, label: untitled.id, cwd: '/same', sameWorkspace: true, createdAt: untitled.header.createdAt }, - ]) - // Only the uncheckpointed session reached a log. - expect(readTitles).toHaveBeenCalledTimes(1) - expect(readTitles.mock.calls[0]?.[0]).toEqual([seeded.id]) - readTitles.mockRestore() - }) - - it('folds the uncheckpointed tail so a query still filters on its titles', async () => { - const ctx = await harness() - const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - const seeded = ctx.sessions.create(SessionId('seeded'), { meta: { cwd: '/same' } }) - seeded.append('session/title', { title: 'Research notes', messageSeqs: [], source: { kind: 'fallback' } }) - withProjectionCache(ctx, {}) - - // The title lives only in the log, and the filter reads labels — so a - // deferred fold would make this session unfindable by its own title. - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'research')) - .resolves.toEqual([ - { sessionId: seeded.id, label: 'Research notes', cwd: '/same', sameWorkspace: true, createdAt: seeded.header.createdAt }, - ]) - }) - - it('folds a cold log once and answers every later query from that fold', async () => { + it('labels a cold session from its checkpoint and reads no log', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) const cold = { id: SessionId('cold'), createdAt: 10, cwd: '/same' } - withProjectionCache(ctx, {}) + withProjectionCache(ctx, { cold: 'Cold checkpoint' }) vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ - { header: { ...target.header }, live: true, persisted: false }, { header: cold, live: false, persisted: true }, ] as never) - const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValue([{ - sessionId: cold.id, - status: 'fulfilled', - value: { session: cold, title: { title: 'Cold title' } }, - }] as never) + const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') - const expected = [{ sessionId: cold.id, label: 'Cold title', cwd: '/same', sameWorkspace: true, createdAt: 10 }] - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'cold')).resolves.toEqual(expected) - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'cold t')).resolves.toEqual(expected) - // A cold log never grows, so the second keystroke reads nothing. - expect(readTitles).toHaveBeenCalledTimes(1) + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'checkpoint')) + .resolves.toEqual([ + { sessionId: cold.id, label: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 }, + ]) + expect(readTitles).not.toHaveBeenCalled() vi.restoreAllMocks() }) - it('remembers that a cold log has no title, and stops reading it', async () => { + it('labels a session no projection answers for by its id, still without a log read', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const seeded = { id: SessionId('seeded'), createdAt: 10, cwd: '/same' } + // Persisted before the cache was composed: the title lives only in its log. withProjectionCache(ctx, {}) vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([ - { header: { id: SessionId('bare'), createdAt: 10 }, live: false, persisted: true }, + { header: seeded, live: false, persisted: true }, ] as never) - const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValue([{ - sessionId: SessionId('bare'), - status: 'fulfilled', - value: { session: {} }, - }] as never) - - const expected = [{ sessionId: SessionId('bare'), label: 'bare', sameWorkspace: false, createdAt: 10 }] - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'bare')).resolves.toEqual(expected) - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'bar')).resolves.toEqual(expected) - expect(readTitles).toHaveBeenCalledTimes(1) - vi.restoreAllMocks() - }) - - it('refolds a cold id whose log was replaced under it', async () => { - const ctx = await harness() - const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - withProjectionCache(ctx, {}) - let createdAt = 10 - vi.spyOn(ctx.sessionQuery, 'listSessions').mockImplementation(() => Promise.resolve([ - { header: { id: SessionId('cold'), createdAt, cwd: '/same' }, live: false, persisted: true }, - ] as never)) const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') - .mockImplementation(() => Promise.resolve([{ - sessionId: SessionId('cold'), - status: 'fulfilled', - value: { session: {}, title: { title: `Title at ${String(createdAt)}` } }, - }] as never)) - - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'title')) - .resolves.toMatchObject([{ label: 'Title at 10' }]) - createdAt = 20 - await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'title')) - .resolves.toMatchObject([{ label: 'Title at 20' }]) - expect(readTitles).toHaveBeenCalledTimes(2) - vi.restoreAllMocks() - }) - - it('leaves the id placeholder when the page fold cannot read the log', async () => { - const ctx = await harness() - const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) - const broken = ctx.sessions.create(SessionId('broken'), { meta: { cwd: '/same' } }) - withProjectionCache(ctx, {}) - const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{ - sessionId: broken.id, - status: 'rejected', - reason: new Error('broken title log'), - }]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ - { sessionId: broken.id, label: broken.id, cwd: '/same', sameWorkspace: true, createdAt: broken.header.createdAt }, + { sessionId: seeded.id, label: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 }, + ]) + // Its own title cannot find it, and discovery still never opens the log. + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([]) + expect(readTitles).not.toHaveBeenCalled() + vi.restoreAllMocks() + }) + + it('labels every session by id when no projection face is composed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(TestSessionQueryEngine) + await ctx.plugin(SessionReferenceResolver) + const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } }) + const other = ctx.sessions.create(SessionId('other'), { meta: { cwd: '/same' } }) + other.append('session/title', { title: 'Unreadable', messageSeqs: [], source: { kind: 'fallback' } }) + + await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([ + { sessionId: other.id, label: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt }, ]) - readTitles.mockRestore() }) it('serves the Remote face with the configured limit and canonical mentions', async () => { @@ -528,38 +469,15 @@ describe('session reference discovery and preparation', () => { )).rejects.toThrow(/invalid session reference URI/) }) - it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => { + it('still matches an unlabeled session on its own metadata', async () => { const ctx = await harness() const target = ctx.sessions.create(SessionId('target')) + // No cwd, no title event: nothing but the id identifies it. const source = ctx.sessions.create(SessionId('source')) - const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots') - readTitles.mockResolvedValueOnce([{ - sessionId: source.id, - status: 'rejected', - reason: new Error('broken title log'), - }]) await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([ { sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt }, ]) - - let releaseTitles: (() => void) | undefined - let titleSignal: AbortSignal | undefined - readTitles.mockImplementationOnce(async (_ids, signal) => { - titleSignal = signal - await new Promise((resolve) => { releaseTitles = resolve }) - return [] - }) - const controller = new AbortController() - const pending = ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source', undefined, controller.signal) - await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') }) - expect(titleSignal).toBe(controller.signal) - const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED')) - controller.abort('autocomplete superseded') - await cancelledTitles - releaseTitles?.() - await Promise.resolve() - readTitles.mockRestore() }) it('projects only the current user/assistant surface and records snapshot metadata', async () => { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 5a0178c36c..a1bb3e35fc 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1708,7 +1708,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise', - description: 'List reference candidates, ranked by working-directory affinity.\n\nA title comes from the projection cache when that cache holds a checkpoint for the session; otherwise it is folded from the session\'s log once and remembered for as long as the log stays cold. Without the cache composed, only the cwd-ranked head of an unfiltered listing is folded, so its tail cannot match a title substring.', + description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so a title only ever comes from a projection read: see SessionReferenceResolver.projectedTitle for which sessions can answer one and which fall back to their id.', parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }], returns: 'candidates labeled by latest title or, when absent, session id.', }, From 520bc3ce75b1498163d182f9f6501c94f5b2d73d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 27 Aug 2026 15:54:20 +0800 Subject: [PATCH 062/130] fix(tools): scope bash SDK example to its schema --- ...8-07-code-mode-executor-collapse.i18n.yaml | 4 +- .../2026-08-07-code-mode-executor-collapse.md | 2 +- ...26-08-07-code-mode-executor-collapse.zh.md | 2 +- packages/core/tools/README.i18n.yaml | 4 +- packages/core/tools/README.md | 4 +- packages/core/tools/README.zh.md | 4 +- packages/core/tools/src/ts-types.ts | 30 ++++++++++-- packages/core/tools/tests/code-mode.spec.ts | 1 + packages/core/tools/tests/ts-types.spec.ts | 49 ++++++++++++++++++- 9 files changed, 84 insertions(+), 16 deletions(-) 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 index 7479e32937..6aae8b549f 100644 --- 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 @@ -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-07-code-mode-executor-collapse.md -2026-08-07-code-mode-executor-collapse.md: 0644342de1583d6c2a6d036027195edb6e33de73 -2026-08-07-code-mode-executor-collapse.zh.md: 013b2c19399da8ceed33e3455e30862c8c7f0363 +2026-08-07-code-mode-executor-collapse.md: 76265d5dd5f37f03c8e56366bf2791ddd2f7cb18 +2026-08-07-code-mode-executor-collapse.zh.md: f2d33993eb815d3f6dcd952557527c9aab5faefb 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 index 0644342de1..76265d5dd5 100644 --- 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 @@ -42,5 +42,5 @@ No provider guarantees interception of unadvertised names; the reported session - `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-observation-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 first-party per-tool guidance. 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. The TypeScript SDK section repeats the distinction next to the generated declarations, labels them as program-only bindings, states that only separately supplied tool schemas grant direct-call availability, and shows a complete `run_code` call around `tools.bash(...)` because the declaration list can otherwise be read as native tool availability. `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. +- The prompt STATES the collapse, in the `tools:code-only` section ordered ahead of first-party per-tool guidance. 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. The TypeScript SDK section repeats the distinction next to the generated declarations, labels them as program-only bindings, and states that only separately supplied tool schemas grant direct-call availability. Because the declaration list can otherwise be read as native tool availability, the section emits a complete `run_code` call around `tools.bash(...)` when the current `bash` parameter schema accepts the example arguments. `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 index 013b2c1939..f2d33993eb 100644 --- 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 @@ -42,5 +42,5 @@ guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装 - `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-observation-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 -- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。TypeScript SDK 段在生成声明旁再次区分两者,将其标为只能在程序内使用的绑定,说明只有单独提供的工具 schema 才赋予直呼权限,并给出以 `run_code` 包住 `tools.bash(...)` 的完整调用,因为声明列表可能被误读为原生工具可用性。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 +- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。TypeScript SDK 段在生成声明旁再次区分两者,将其标为只能在程序内使用的绑定,并说明只有单独提供的工具 schema 才赋予直呼权限。声明列表可能被误读为原生工具可用性,因此当当前 `bash` 参数 schema 接受示例参数时,该段会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 - 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index ae1208ad4a..3c177a036c 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: 86c2f3549248d7c93f4bd4bc9ca10c8ab64b2bb0 -README.zh.md: caffa098090b142e4fd584410ac12a4c2574a6b0 +README.md: ed62ac8314e3bbd3720921738b9cbe3b0ee7315a +README.zh.md: 1c42cbffe27bf2affdf7818085896c6d70a5655c diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 86c2f35492..ed62ac8314 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -168,9 +168,9 @@ 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 instructions identify generated declarations as program-only bindings and show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this Code Mode API; under `code` the prompt also carries the `tools:code-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. +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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this Code Mode API; under `code` the prompt also carries the `tools:code-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. -##### Code Mode SDK instructions +##### TypeScript Code Mode SDK instructions with bash ```markdown ## Writing code for run_code diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index caffa09809..1c42cbffe2 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -168,9 +168,9 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。说明会把生成声明明确标为只能在程序内使用的绑定,并给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 Code Mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:code-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 +Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 Code Mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:code-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 -##### Code Mode SDK 说明 +##### 带 bash 的 TypeScript Code Mode SDK 说明 ```markdown ## Writing code for run_code diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index 7ffc1a5c6e..a5d36a5ce3 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -249,11 +249,9 @@ export function jsonSchemaToTs(schema: unknown, indent = 0): string { /** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */ const SDK_INSTRUCTIONS = `## Writing code for run_code -\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly. When no separate \`bash\` schema is supplied, invoke a declared \`bash\` binding inside \`run_code\`: +\`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly.` -\`run_code({ code: "return await tools.bash({ command: 'pwd', description: 'Show current directory' })", description: "Show current directory" })\` - -Inside the program: +const SDK_PROGRAM_INSTRUCTIONS = `Inside the program: - Call tools as \`await tools.name(args)\` — quoted access for exotic names: \`tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON. - A FAILED tool call rejects with \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose \`message\` is human-readable — \`try/catch\` it to handle and continue. @@ -262,6 +260,28 @@ Inside the program: Program-only SDK bindings:` +/** Whether one string schema accepts the literal used by the bash example. */ +function acceptsExampleString(schema: JsonSchemaNode | undefined, value: string): boolean { + return schema?.type === 'string' + && (schema.const === undefined || schema.const === value) + && (schema.enum === undefined || schema.enum.includes(value)) +} + +/** Render the bash example only when its literal arguments satisfy the current parameter schema. */ +function renderBashExample(schemas: ToolSdkSchema[]): string { + const bash = schemas.find(schema => schema.name === 'bash') + if (bash === undefined) return '' + const parameters = bash.parameters as JsonSchemaNode + if (parameters.type !== 'object') return '' + const required = parameters.required ?? [] + if (required.some(name => name !== 'command' && name !== 'description')) return '' + if (!acceptsExampleString(parameters.properties?.command, 'pwd')) return '' + const needsDescription = required.includes('description') + if (needsDescription && !acceptsExampleString(parameters.properties?.description, 'Show current directory')) return '' + const description = needsDescription ? ", description: 'Show current directory'" : '' + return ` When no separate \`bash\` schema is supplied, invoke a declared \`bash\` binding inside \`run_code\`:\n\n\`run_code({ code: "return await tools.bash({ command: 'pwd'${description} })", description: "Show current directory" })\`` +} + /** * Render the full `tools:sdk` prompt section: the fixed usage instructions * plus one `declare const tools` interface covering every given tool. @@ -293,5 +313,5 @@ export function renderToolsSdk(schemas: ToolSdkSchema[]): string { ['declare const tools: {', ' [K in ToolName]: (args: ToolArgsMap[K]) => Promise;', '}'].join('\n'), ].join('\n\n') const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }' - return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\`` + return `${SDK_INSTRUCTIONS}${renderBashExample(sorted)}\n\n${SDK_PROGRAM_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\`` } diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index b6149d88fd..6bad231737 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -133,6 +133,7 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).toContain('declare const tools: {') expect(sdk?.text).toContain('echo: {') expect(sdk?.text).not.toContain('run_code:') + expect(sdk?.text).not.toContain('tools.bash(') }) it("mode 'code' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => { diff --git a/packages/core/tools/tests/ts-types.spec.ts b/packages/core/tools/tests/ts-types.spec.ts index 39179086b9..5a3f0edca5 100644 --- a/packages/core/tools/tests/ts-types.spec.ts +++ b/packages/core/tools/tests/ts-types.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts' import type { ToolSdkSchema } from '@deepseek-ai/dsh-tools/src/ts-types.ts' +import type { JsonSchemaNode } from '@deepseek-ai/dsh-tools/src/json-schema.ts' import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools' describe('jsonSchemaToTs', () => { @@ -164,11 +165,57 @@ describe('renderToolsSdk', () => { const text = renderToolsSdk([bash]) expect(text).toContain('A declaration does not make its name a directly callable tool') expect(text).toContain('only names supplied as separate tool schemas may be called directly') - expect(text).toContain('`run_code({ code: "return await tools.bash(') + expect(text).toContain('`run_code({ code: "return await tools.bash({ command: \'pwd\', description: \'Show current directory\' })"') expect(text).toContain('Program-only SDK bindings:') expect(text).not.toContain('The available tools:') }) + it('only shows a bash example accepted by the declared binding', () => { + expect(renderToolsSdk([exotic])).not.toContain('tools.bash(') + + const commandOnly = { + ...bash, + parameters: parameterSchemaSpecToJsonSchema({ + command: { type: 'string', required: true }, + }) as unknown as Record, + } + expect(renderToolsSdk([commandOnly])) + .toContain('tools.bash({ command: \'pwd\' })') + + const incompatible = { + ...bash, + parameters: parameterSchemaSpecToJsonSchema({ + command: { type: 'string', required: true }, + cwd: { type: 'string', required: true }, + }) as unknown as Record, + } + expect(renderToolsSdk([incompatible])).not.toContain('tools.bash(') + + const parameters = (value: JsonSchemaNode): ToolSdkSchema => ({ + ...bash, + parameters: value as Record, + }) + const rejected: JsonSchemaNode[] = [ + { type: 'string' }, + { type: 'object', properties: {} }, + { type: 'object', properties: { command: { type: 'number' } } }, + { type: 'object', properties: { command: { type: 'string', const: 'date' } } }, + { type: 'object', properties: { command: { type: 'string', enum: ['date'] } } }, + { + type: 'object', + properties: { command: { type: 'string' }, description: { type: 'number' } }, + required: ['command', 'description'], + }, + ] + for (const schema of rejected) expect(renderToolsSdk([parameters(schema)])).not.toContain('tools.bash(') + + const constrained = parameters({ + type: 'object', + properties: { command: { type: 'string', const: 'pwd', enum: ['pwd'] } }, + }) + expect(renderToolsSdk([constrained])).toContain('tools.bash({ command: \'pwd\' })') + }) + it('is deterministic: same tool set, byte-identical text regardless of input order', () => { expect(renderToolsSdk([bash, exotic])).toBe(renderToolsSdk([exotic, bash])) // Equal names sort stably (the comparator's equal arm). From 189fb34797ca88439a907011a77a3c44e31d45a2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:24:36 +0800 Subject: [PATCH 063/130] feat(inspector): define shared protocol foundation --- knip.json | 9 + packages/experimental/inspector/package.json | 67 ++++ .../experimental/inspector/src/invariant.ts | 22 ++ .../inspector/src/shared/bridge/buffer.ts | 157 ++++++++ .../inspector/src/shared/bridge/codec.ts | 4 + .../src/shared/bridge/control-codec.ts | 153 ++++++++ .../inspector/src/shared/bridge/ids.ts | 27 ++ .../src/shared/bridge/messages/control.ts | 72 ++++ .../src/shared/bridge/messages/observation.ts | 363 ++++++++++++++++++ .../bridge/messages/runtime/command-codec.ts | 134 +++++++ .../bridge/messages/runtime/commands.ts | 101 +++++ .../bridge/messages/runtime/console-frames.ts | 147 +++++++ .../shared/bridge/messages/runtime/frames.ts | 147 +++++++ .../shared/bridge/messages/runtime/index.ts | 5 + .../bridge/messages/runtime/value-codec.ts | 334 ++++++++++++++++ .../shared/bridge/messages/sources/codec.ts | 127 ++++++ .../bridge/messages/sources/commands.ts | 47 +++ .../shared/bridge/messages/sources/frames.ts | 143 +++++++ .../shared/bridge/messages/sources/index.ts | 5 + .../inspector/src/shared/bridge/publisher.ts | 24 ++ .../inspector/src/shared/bridge/rpc.ts | 182 +++++++++ .../inspector/src/shared/bridge/validation.ts | 3 + .../inspector/src/shared/bridge/version.ts | 2 + .../inspector/src/shared/cdp/capabilities.ts | 28 ++ .../inspector/src/shared/cdp/console.ts | 46 +++ .../inspector/src/shared/cdp/debugger.ts | 78 ++++ .../inspector/src/shared/cdp/errors.ts | 30 ++ .../inspector/src/shared/cdp/ids.ts | 12 + .../inspector/src/shared/cdp/index.ts | 11 + .../inspector/src/shared/cdp/operations.ts | 80 ++++ .../inspector/src/shared/cdp/property.ts | 31 ++ .../inspector/src/shared/cdp/realm.ts | 157 ++++++++ .../inspector/src/shared/cdp/remote-object.ts | 78 ++++ .../inspector/src/shared/cdp/sources.ts | 19 + .../inspector/src/shared/identity.ts | 19 + .../inspector/src/shared/index.ts | 18 + .../experimental/inspector/src/shared/json.ts | 79 ++++ .../inspector/src/shared/validation.ts | 93 +++++ .../inspector/tests/protocol.host.spec.ts | 265 +++++++++++++ .../tests/source-buffer.host.spec.ts | 43 +++ .../inspector/tsconfig.client.json | 96 +++++ .../experimental/inspector/tsconfig.host.json | 155 ++++++++ packages/experimental/inspector/tsconfig.json | 11 + .../experimental/inspector/tsdown.config.ts | 22 ++ pnpm-lock.yaml | 43 +++ tsconfig.base.json | 3 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + vitest.config.ts | 6 + vitest.e2e.config.ts | 5 +- vitest.web.config.ts | 1 + 51 files changed, 3705 insertions(+), 1 deletion(-) create mode 100644 packages/experimental/inspector/package.json create mode 100644 packages/experimental/inspector/src/invariant.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/buffer.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/codec.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/control-codec.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/ids.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/control.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/observation.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/publisher.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/rpc.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/validation.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/version.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/capabilities.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/console.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/debugger.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/errors.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/ids.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/index.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/operations.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/property.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/realm.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/remote-object.ts create mode 100644 packages/experimental/inspector/src/shared/cdp/sources.ts create mode 100644 packages/experimental/inspector/src/shared/identity.ts create mode 100644 packages/experimental/inspector/src/shared/index.ts create mode 100644 packages/experimental/inspector/src/shared/json.ts create mode 100644 packages/experimental/inspector/src/shared/validation.ts create mode 100644 packages/experimental/inspector/tests/protocol.host.spec.ts create mode 100644 packages/experimental/inspector/tests/source-buffer.host.spec.ts create mode 100644 packages/experimental/inspector/tsconfig.client.json create mode 100644 packages/experimental/inspector/tsconfig.host.json create mode 100644 packages/experimental/inspector/tsconfig.json create mode 100644 packages/experimental/inspector/tsdown.config.ts diff --git a/knip.json b/knip.json index 6fa4cac826..026215eb4f 100644 --- a/knip.json +++ b/knip.json @@ -45,6 +45,15 @@ "@deepseek-ai/dsh-client-ui-directory-picker-native" ] }, + "packages/experimental/inspector": { + "entry": [ + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/extensions/cordis-host-runner": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json new file mode 100644 index 0000000000..c13ed177d2 --- /dev/null +++ b/packages/experimental/inspector/package.json @@ -0,0 +1,67 @@ +{ + "name": "@deepseek-ai/dsh-experimental-inspector", + "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", + "version": "0.1.1-rc.2", + "private": true, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/inspector" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [], + "platform": "web", + "immediately": true + } + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "ws": "^8.21.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "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-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/ws": "^8.18.1", + "playwright": "^1.49.0", + "tsx": "^4.19.2" + } +} diff --git a/packages/experimental/inspector/src/invariant.ts b/packages/experimental/inspector/src/invariant.ts new file mode 100644 index 0000000000..33dccfbe9e --- /dev/null +++ b/packages/experimental/inspector/src/invariant.ts @@ -0,0 +1,22 @@ +/** Package-owned invariant companion for the experimental Inspector. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-experimental-inspector' + +/** Cordis companion plugin name. */ +export const name = 'experimental-inspector-invariant' + +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: wire parsing, generations, Worker lifecycle, and CDP + * sessions reject invalid relationships in their owning operations. + */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/experimental/inspector/src/shared/bridge/buffer.ts b/packages/experimental/inspector/src/shared/bridge/buffer.ts new file mode 100644 index 0000000000..c058fbe18a --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/buffer.ts @@ -0,0 +1,157 @@ +/** Realm-neutral bounded buffering for Host and Client observation sources. */ + +import type { InspectorSourceGeneration, InspectorSourceId } from './ids.ts' +import { isJsonValue, jsonByteLength, type InspectorJsonValue } from '../json.ts' +import type { InspectorRecordInput, SourceAppendFrame, SourceReplaceFrame } from './messages/observation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from './version.ts' + +const SOURCE_FRAME_OVERHEAD_BYTES = 4_096 + +/** Limits and declared topics shared by both source transports. */ +export interface InspectorSourceBufferOptions { + readonly topics: readonly string[] + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly maxRecordsPerFrame: number + readonly maxFrameBytes: number +} + +interface QueuedRecord { + sequence: number + readonly bytes: number + readonly record: InspectorRecordInput +} + +/** + * Owns retained state, queued events, and source-local sequencing independently + * of whether frames travel over MessagePort or WebSocket. + */ +export class InspectorSourceBuffer { + private readonly queue: QueuedRecord[] = [] + private readonly state = new Map() + private queuedBytes = 0 + private nextSequence = 1 + private expectedSequence = 1 + + constructor(private readonly options: InspectorSourceBufferOptions) {} + + /** Whether at least one observation is waiting for transport. */ + get hasPending(): boolean { + return this.queue.length > 0 + } + + /** + * Validate and enqueue one observation, dropping the oldest prefix as needed. + * @param topic - Declared domain topic. + * @param payload - Lossless JSON payload. + * @param monotonicMs - Finite source-clock timestamp. + */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs: number): void { + this.enqueue(this.record(topic, payload, monotonicMs)) + } + + /** + * Replace one retained topic and enqueue the same observation for live delivery. + * @param topic - Declared state topic. + * @param payload - Lossless JSON payload retained for replacement frames. + * @param monotonicMs - Finite source-clock timestamp. + */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs: number): void { + const record = this.record(topic, payload, monotonicMs) + const previous = this.state.get(topic) + this.state.set(topic, record) + if (!this.stateFits()) { + if (previous === undefined) this.state.delete(topic) + else this.state.set(topic, previous) + throw new Error('inspector: source state exceeds the source-frame byte limit') + } + this.enqueue(record) + } + + /** + * Build a complete state replacement and absorb every preceding queue drop. + * @param sourceId - Logical source identity. + * @param generation - Current transport generation. + * @returns A replacement frame whose sequence is the next append position. + */ + replacement(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): SourceReplaceFrame { + const nextSequence = this.queue[0]?.sequence ?? this.nextSequence + this.expectedSequence = nextSequence + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/replace', + sourceId, + generation, + nextSequence, + records: [...this.state.values()], + } + } + + /** + * Remove and sequence the next transport-sized observation batch. + * @param sourceId - Logical source identity. + * @param generation - Current transport generation. + * @returns The next append frame, or `undefined` when the queue is empty. + */ + takeBatch(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): SourceAppendFrame | undefined { + if (this.queue.length === 0) return undefined + const batch: QueuedRecord[] = [] + let batchBytes = SOURCE_FRAME_OVERHEAD_BYTES + const first = this.queue[0] + if (first === undefined) throw new Error('inspector: non-empty source queue has no first record') + while (batch.length < this.options.maxRecordsPerFrame && this.queue.length > 0) { + const candidate = this.queue[0] + if (candidate === undefined) break + if (candidate.sequence !== first.sequence + batch.length) break + if (batch.length > 0 && batchBytes + candidate.bytes > this.options.maxFrameBytes) break + this.queue.shift() + batch.push(candidate) + batchBytes += candidate.bytes + } + this.queuedBytes -= batch.reduce((sum, item) => sum + item.bytes, 0) + const firstSequence = first.sequence + const frame: SourceAppendFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append', + sourceId, + generation, + firstSequence, + droppedBefore: firstSequence - this.expectedSequence, + records: batch.map(item => item.record), + } + this.expectedSequence = firstSequence + frame.records.length + return frame + } + + private record(topic: string, payload: InspectorJsonValue, monotonicMs: number): InspectorRecordInput { + if (topic.length === 0 || topic.length > 128) { + throw new Error('inspector: topic must contain 1 to 128 characters') + } + if (!this.options.topics.includes('*') && !this.options.topics.includes(topic)) { + throw new Error(`inspector: source does not declare topic ${JSON.stringify(topic)}`) + } + if (!isJsonValue(payload)) throw new Error('inspector: source payload must be lossless JSON data') + if (!Number.isFinite(monotonicMs)) throw new Error('inspector: monotonicMs must be finite') + return { monotonicMs, topic, payload } + } + + private enqueue(record: InspectorRecordInput): void { + const bytes = jsonByteLength(record as unknown as InspectorJsonValue) + const sequence = this.nextSequence++ + if (bytes + SOURCE_FRAME_OVERHEAD_BYTES > this.options.maxFrameBytes) { + return + } + this.queue.push({ sequence, bytes, record }) + this.queuedBytes += bytes + while (this.queue.length > this.options.maxQueuedRecords || this.queuedBytes > this.options.maxQueuedBytes) { + const dropped = this.queue.shift() + if (dropped === undefined) break + this.queuedBytes -= dropped.bytes + } + } + + private stateFits(): boolean { + return jsonByteLength([...this.state.values()] as unknown as InspectorJsonValue) + SOURCE_FRAME_OVERHEAD_BYTES + <= this.options.maxFrameBytes + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/codec.ts b/packages/experimental/inspector/src/shared/bridge/codec.ts new file mode 100644 index 0000000000..b32b380b1d --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/codec.ts @@ -0,0 +1,4 @@ +/** Bridge-facing exports for lossless JSON values and common wire validators. */ + +export * from '../json.ts' +export * from '../validation.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/control-codec.ts b/packages/experimental/inspector/src/shared/bridge/control-codec.ts new file mode 100644 index 0000000000..95f307c312 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/control-codec.ts @@ -0,0 +1,153 @@ +/** Exact decoders for Host, Worker, and injected Client lifecycle values. */ + +import type { + InspectorClientBootstrap, + InspectorHostControl, + InspectorWorkerConfig, + InspectorWorkerControl, +} from './messages/control.ts' +import { isPlainObject } from '../json.ts' +import { exactKeys, exactObject } from '../validation.ts' + +/** + * Decode the structured-cloned Worker configuration. + * @param value - Untrusted workerData config value. + * @returns The validated Worker configuration. + */ +export function parseInspectorWorkerConfig(value: unknown): InspectorWorkerConfig { + const record = exactObject(value, [ + 'host', 'startPort', 'targetId', 'clientToken', 'clientOrigins', 'maxSourceFrameBytes', + 'maxSourceRecordsPerFrame', 'maxRetainedRequests', 'maxJournalBytes', 'clientRuntimeTimeoutMs', 'maxCordisNodes', + 'maxDisconnectedCordisTrees', 'maxClientSourceBytes', + ], 'Worker config') + if (record.host !== '127.0.0.1') throw new Error('inspector protocol: Worker host must be 127.0.0.1') + if (typeof record.targetId !== 'string' || record.targetId.length === 0) { + throw new Error('inspector protocol: Worker targetId must be a non-empty string') + } + if (typeof record.clientToken !== 'string' || record.clientToken.length === 0) { + throw new Error('inspector protocol: Worker clientToken must be a non-empty string') + } + if (!Array.isArray(record.clientOrigins) || !record.clientOrigins.every(origin => typeof origin === 'string')) { + throw new Error('inspector protocol: Worker clientOrigins must be strings') + } + const startPort = natural(record.startPort, 'startPort', true) + if (startPort > 65_535) throw new Error('inspector protocol: Worker startPort must not exceed 65535') + return { + host: record.host, + startPort, + targetId: record.targetId, + clientToken: record.clientToken, + clientOrigins: record.clientOrigins, + maxSourceFrameBytes: natural(record.maxSourceFrameBytes, 'maxSourceFrameBytes'), + maxSourceRecordsPerFrame: natural(record.maxSourceRecordsPerFrame, 'maxSourceRecordsPerFrame'), + maxRetainedRequests: natural(record.maxRetainedRequests, 'maxRetainedRequests'), + maxJournalBytes: natural(record.maxJournalBytes, 'maxJournalBytes'), + clientRuntimeTimeoutMs: natural(record.clientRuntimeTimeoutMs, 'clientRuntimeTimeoutMs'), + maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'), + maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'), + maxDisconnectedCordisTrees: natural(record.maxDisconnectedCordisTrees, 'maxDisconnectedCordisTrees', true), + } +} + +/** + * Decode one Host-to-Worker lifecycle command. + * @param value - Untrusted control message. + * @returns The validated Host command. + */ +export function parseInspectorHostControl(value: unknown): InspectorHostControl { + const record = exactObject(value, ['type'], 'Host control message') + if (record.type !== 'shutdown') throw new Error('inspector protocol: unknown Host control message') + return { type: 'shutdown' } +} + +/** + * Decode one Worker-to-Host lifecycle event. + * @param value - Untrusted control message. + * @returns The validated Worker event. + */ +export function parseInspectorWorkerControl(value: unknown): InspectorWorkerControl { + const record = exactObjectByType(value, 'Worker control message') + switch (record.type) { + case 'ready': + exactKeys(record, ['type', 'host', 'port', 'targetId'], 'Worker ready message') + if (typeof record.host !== 'string' || typeof record.targetId !== 'string') { + throw new Error('inspector protocol: invalid Worker ready identity') + } + return { + type: 'ready', + host: record.host, + port: natural(record.port, 'port', true), + targetId: record.targetId, + } + case 'failure': + exactKeys(record, ['type', 'message'], 'Worker failure message') + if (typeof record.message !== 'string') throw new Error('inspector protocol: invalid Worker failure') + return { type: 'failure', message: record.message } + case 'stopped': + exactKeys(record, ['type'], 'Worker stopped message') + return { type: 'stopped' } + default: + throw new Error('inspector protocol: unknown Worker control message') + } +} + +/** + * Decode bootstrap data injected into the browser global. + * @param value - Untrusted injected value. + * @returns The validated Client bootstrap. + */ +export function parseInspectorClientBootstrap(value: unknown): InspectorClientBootstrap { + const record = exactObject(value, [ + 'endpoint', 'protocol', 'maxQueuedRecords', 'maxQueuedBytes', 'maxRecordsPerFrame', 'maxFrameBytes', + 'reconnectBaseMs', 'reconnectMaxMs', 'queryTimeoutMs', 'maxRuntimeObjectsPerSession', + 'maxRuntimePropertiesPerResult', 'maxCordisNodes', 'maxClientSourceBytes', + ], 'Client bootstrap') + if (typeof record.endpoint !== 'string' || typeof record.protocol !== 'string') { + throw new Error('inspector protocol: Client bootstrap endpoint and protocol must be strings') + } + let endpoint: URL + try { + endpoint = new URL(record.endpoint) + } catch { + throw new Error('inspector protocol: Client bootstrap endpoint must be an absolute URL') + } + if (endpoint.protocol !== 'ws:' || endpoint.hostname !== '127.0.0.1') { + throw new Error('inspector protocol: Client bootstrap endpoint must use ws on 127.0.0.1') + } + if (record.protocol.length === 0 || record.protocol.length > 256) { + throw new Error('inspector protocol: Client bootstrap protocol must contain 1 to 256 characters') + } + const bootstrap: InspectorClientBootstrap = { + endpoint: record.endpoint, + protocol: record.protocol, + maxQueuedRecords: natural(record.maxQueuedRecords, 'maxQueuedRecords'), + maxQueuedBytes: natural(record.maxQueuedBytes, 'maxQueuedBytes'), + maxRecordsPerFrame: natural(record.maxRecordsPerFrame, 'maxRecordsPerFrame'), + maxFrameBytes: natural(record.maxFrameBytes, 'maxFrameBytes'), + reconnectBaseMs: natural(record.reconnectBaseMs, 'reconnectBaseMs'), + reconnectMaxMs: natural(record.reconnectMaxMs, 'reconnectMaxMs'), + queryTimeoutMs: natural(record.queryTimeoutMs, 'queryTimeoutMs'), + maxRuntimeObjectsPerSession: natural(record.maxRuntimeObjectsPerSession, 'maxRuntimeObjectsPerSession'), + maxRuntimePropertiesPerResult: natural(record.maxRuntimePropertiesPerResult, 'maxRuntimePropertiesPerResult'), + maxClientSourceBytes: natural(record.maxClientSourceBytes, 'maxClientSourceBytes'), + maxCordisNodes: natural(record.maxCordisNodes, 'maxCordisNodes'), + } + if (bootstrap.reconnectMaxMs < bootstrap.reconnectBaseMs) { + throw new Error('inspector protocol: reconnectMaxMs must be at least reconnectBaseMs') + } + return bootstrap +} + +function exactObjectByType(value: unknown, label: string): Record { + if (!isPlainObject(value) || typeof value.type !== 'string') { + throw new Error(`inspector protocol: ${label} must have a type`) + } + return value +} + +function natural(value: unknown, label: string, zero = false): number { + if (!Number.isSafeInteger(value) || (value as number) < (zero ? 0 : 1)) { + throw new Error(`inspector protocol: ${label} must be ${zero ? 'a non-negative' : 'a positive'} safe integer`) + } + return value as number +} diff --git a/packages/experimental/inspector/src/shared/bridge/ids.ts b/packages/experimental/inspector/src/shared/bridge/ids.ts new file mode 100644 index 0000000000..7052c2fe72 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/ids.ts @@ -0,0 +1,27 @@ +/** Opaque identifiers owned by the cross-realm Inspector bridge. */ + +import type { InspectorId } from '../identity.ts' + +export { inspectorId } from '../identity.ts' +export type { InspectorId } from '../identity.ts' + +/** Stable identity of one logical observation source. */ +export type InspectorSourceId = InspectorId<'InspectorSourceId'> + +/** Identity of one source connection generation. */ +export type InspectorSourceGeneration = InspectorId<'InspectorSourceGeneration'> + +/** Identity of one DevTools connection's Client Runtime state. */ +export type ClientRuntimeSessionId = InspectorId<'ClientRuntimeSessionId'> + +/** Identity of one in-flight Worker-to-Client Runtime operation. */ +export type ClientRuntimeRequestId = InspectorId<'ClientRuntimeRequestId'> + +/** Identity of one DevTools connection's Client source catalog session. */ +export type ClientSourceSessionId = InspectorId<'ClientSourceSessionId'> + +/** Identity of one in-flight Worker-to-Client source operation. */ +export type ClientSourceRequestId = InspectorId<'ClientSourceRequestId'> + +/** Opaque reference to an object retained inside one Client Runtime session. */ +export type ClientRemoteObjectHandle = InspectorId<'ClientRemoteObjectHandle'> diff --git a/packages/experimental/inspector/src/shared/bridge/messages/control.ts b/packages/experimental/inspector/src/shared/bridge/messages/control.ts new file mode 100644 index 0000000000..9091168d02 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/control.ts @@ -0,0 +1,72 @@ +/** Host-to-Worker lifecycle messages and Worker readiness results. */ + +/** Fully resolved Worker configuration. */ +export interface InspectorWorkerConfig { + readonly host: '127.0.0.1' + /** First port to bind; zero delegates selection to the operating system. */ + readonly startPort: number + readonly targetId: string + readonly clientToken: string + readonly clientOrigins: readonly string[] + readonly maxSourceFrameBytes: number + readonly maxSourceRecordsPerFrame: number + readonly maxRetainedRequests: number + readonly maxJournalBytes: number + readonly clientRuntimeTimeoutMs: number + readonly maxClientSourceBytes: number + readonly maxCordisNodes: number + readonly maxDisconnectedCordisTrees: number +} + +/** Structured-clone payload used to start the Inspector Worker. */ +export interface InspectorWorkerBoot { + readonly config: InspectorWorkerConfig + readonly hostSourcePort: Port +} + +/** Host request to stop accepting traffic and close every Worker-owned resource. */ +export interface InspectorWorkerShutdown { + readonly type: 'shutdown' +} + +/** Every control message sent from Host to Worker after boot. */ +export type InspectorHostControl = InspectorWorkerShutdown + +/** Worker endpoint readiness. */ +export interface InspectorWorkerReady { + readonly type: 'ready' + readonly host: string + readonly port: number + readonly targetId: string +} + +/** Worker startup or runtime failure. */ +export interface InspectorWorkerFailure { + readonly type: 'failure' + readonly message: string +} + +/** Worker completed graceful shutdown. */ +export interface InspectorWorkerStopped { + readonly type: 'stopped' +} + +/** Every control message sent from Worker to Host. */ +export type InspectorWorkerControl = InspectorWorkerReady | InspectorWorkerFailure | InspectorWorkerStopped + +/** Browser bootstrap injected by the Host plugin. */ +export interface InspectorClientBootstrap { + readonly endpoint: string + readonly protocol: string + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly maxRecordsPerFrame: number + readonly maxFrameBytes: number + readonly reconnectBaseMs: number + readonly reconnectMaxMs: number + readonly queryTimeoutMs: number + readonly maxRuntimeObjectsPerSession: number + readonly maxRuntimePropertiesPerResult: number + readonly maxClientSourceBytes: number + readonly maxCordisNodes: number +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/observation.ts b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts new file mode 100644 index 0000000000..1127eb0d85 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts @@ -0,0 +1,363 @@ +/** Versioned source lifecycle, observation, and extension frames shared by both carriers. */ + +import { inspectorId, type InspectorSourceGeneration, type InspectorSourceId } from '../ids.ts' +import { isJsonValue, isPlainObject, type InspectorJsonValue } from '../../json.ts' +import { exactKeys } from '../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../version.ts' +import { + parseClientConsoleCapability, + parseClientConsoleControlFrame, + parseClientConsoleEventFrame, + parseClientRuntimeCapability, + parseClientRuntimeRequestFrame, + parseClientRuntimeResponseFrame, + parseClientRuntimeSessionClosedFrame, + type ClientConsoleCapability, + type ClientConsoleDisableFrame, + type ClientConsoleEnableFrame, + type ClientConsoleEventFrame, + type ClientRuntimeCapability, + type ClientRuntimeRequestFrame, + type ClientRuntimeResponseFrame, + type ClientRuntimeSessionClosedFrame, +} from './runtime/index.ts' +import { + parseClientSourceRequestFrame, + parseClientSourceResponseFrame, + parseClientSourceSessionClosedFrame, + parseClientSourcesCapability, + type ClientSourceRequestFrame, + type ClientSourceResponseFrame, + type ClientSourceSessionClosedFrame, + type ClientSourcesCapability, +} from './sources/index.ts' + +export { INSPECTOR_PROTOCOL_VERSION } from '../version.ts' + +/** Realm producing observations. */ +export type InspectorSourceKind = 'host' | 'client' + +/** Optional protocols implemented by one source generation. */ +export type InspectorSourceCapability = ClientRuntimeCapability | ClientConsoleCapability | ClientSourcesCapability + +/** One logical source and connection generation. */ +export interface InspectorSourceDescriptor { + /** Producer identity retained across transport reconnects. */ + readonly sourceId: InspectorSourceId + /** One transport admission, replaced on every reconnect. */ + readonly generation: InspectorSourceGeneration + readonly kind: InspectorSourceKind + readonly label: string + readonly timeOriginMs: number + readonly capabilities: readonly InspectorSourceCapability[] +} + +/** One domain-owned observation before its sequence is assigned. */ +export interface InspectorRecordInput { + readonly monotonicMs: number + readonly topic: string + readonly payload: InspectorJsonValue +} + +/** Initial source handshake. */ +export interface SourceOpenFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/open' + readonly source: InspectorSourceDescriptor + readonly topics: readonly string[] +} + +/** Replace one source's current state after opening or resynchronization. */ +export interface SourceReplaceFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/replace' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly nextSequence: number + readonly records: readonly InspectorRecordInput[] +} + +/** Append one contiguous observation batch. */ +export interface SourceAppendFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/append' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly firstSequence: number + readonly droppedBefore: number + readonly records: readonly InspectorRecordInput[] +} + +/** Clean source closure. */ +export interface SourceCloseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/close' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration +} + +/** Every source-to-Worker frame. */ +export type SourceToWorkerFrame = + | SourceOpenFrame + | SourceReplaceFrame + | SourceAppendFrame + | SourceCloseFrame + | ClientConsoleEventFrame + | ClientRuntimeResponseFrame + | ClientSourceResponseFrame + +/** Worker acceptance of one source generation. */ +export interface SourceAcceptedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/accepted' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration +} + +/** Worker request for a complete source-state replacement. */ +export interface SourceResnapshotFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/resnapshot' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly expectedSequence: number + readonly reason: string +} + +/** Rejection of one malformed or incompatible source connection. */ +export interface SourceRejectedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/rejected' + readonly code: 'invalid-frame' | 'version-mismatch' | 'unauthorized' + readonly message: string +} + +/** Every Worker-to-source control frame. */ +export type WorkerToSourceFrame = + | SourceAcceptedFrame + | SourceResnapshotFrame + | SourceRejectedFrame + | ClientConsoleEnableFrame + | ClientConsoleDisableFrame + | ClientRuntimeRequestFrame + | ClientRuntimeSessionClosedFrame + | ClientSourceRequestFrame + | ClientSourceSessionClosedFrame + +/** + * Parse and rebuild one Worker control frame received by a source. + * @param value - Untrusted decoded wire value. + * @returns The validated Worker-to-source frame. + */ +export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame { + if (!isJsonValue(value) + || !isPlainObject(value) + || value.v !== INSPECTOR_PROTOCOL_VERSION + || typeof value.t !== 'string') { + throw new Error('inspector protocol: invalid Worker source frame') + } + if (value.t === 'source/rejected') { + exactKeys(value, ['v', 't', 'code', 'message'], 'source/rejected frame') + if ((value.code !== 'invalid-frame' && value.code !== 'version-mismatch' && value.code !== 'unauthorized') + || typeof value.message !== 'string') { + throw new Error('inspector protocol: invalid source/rejected frame') + } + return { v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: value.code, message: value.message } + } + if (value.t === 'client-runtime/request') return parseClientRuntimeRequestFrame(value) + if (value.t === 'client-runtime/session-closed') return parseClientRuntimeSessionClosedFrame(value) + if (value.t === 'client-sources/request') return parseClientSourceRequestFrame(value) + if (value.t === 'client-sources/session-closed') return parseClientSourceSessionClosedFrame(value) + if (value.t === 'client-console/enable' || value.t === 'client-console/disable') { + return parseClientConsoleControlFrame(value) + } + const common = { + v: INSPECTOR_PROTOCOL_VERSION, + sourceId: sourceId(value.sourceId), + generation: generation(value.generation), + } as const + if (value.t === 'source/accepted') { + exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/accepted frame') + return { ...common, t: 'source/accepted' } + } + if (value.t === 'source/resnapshot' + && typeof value.reason === 'string') { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'expectedSequence', 'reason'], 'source/resnapshot frame') + return { + ...common, + t: 'source/resnapshot', + expectedSequence: natural(value.expectedSequence, 'expectedSequence'), + reason: value.reason, + } + } + throw new Error(`inspector protocol: unknown Worker source frame ${JSON.stringify(value.t)}`) +} + +/** + * Parse and rebuild one source frame received at a process or network boundary. + * @param value - Untrusted decoded wire value. + * @param maxRecords - Maximum records admitted in one frame. + * @returns The validated source-to-Worker frame. + */ +export function parseSourceFrame(value: unknown, maxRecords: number): SourceToWorkerFrame { + if (!isJsonValue(value) || !isPlainObject(value)) { + throw new Error('inspector protocol: source frame must be a lossless JSON object') + } + if (value.v !== INSPECTOR_PROTOCOL_VERSION) { + throw new Error(`inspector protocol: unsupported version ${JSON.stringify(value.v)}`) + } + switch (value.t) { + case 'source/open': + return parseOpen(value) + case 'source/replace': + return parseRecordsFrame(value, maxRecords, true) + case 'source/append': + return parseRecordsFrame(value, maxRecords, false) + case 'source/close': + exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/close frame') + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/close', + sourceId: sourceId(value.sourceId), + generation: generation(value.generation), + } + case 'client-runtime/response': + return parseClientRuntimeResponseFrame(value) + case 'client-console/event': + return parseClientConsoleEventFrame(value) + case 'client-sources/response': + return parseClientSourceResponseFrame(value) + default: + throw new Error(`inspector protocol: unknown source frame ${JSON.stringify(value.t)}`) + } +} + +function parseOpen(value: Record): SourceOpenFrame { + exactKeys(value, ['v', 't', 'source', 'topics'], 'source/open frame') + if (!isPlainObject(value.source) || !Array.isArray(value.topics)) { + throw new Error('inspector protocol: source/open needs source and topics') + } + const source = value.source + exactKeys(source, ['sourceId', 'generation', 'kind', 'label', 'timeOriginMs', 'capabilities'], 'source descriptor') + const kind = source.kind + if (kind !== 'host' && kind !== 'client') throw new Error('inspector protocol: invalid source kind') + if (typeof source.label !== 'string' || source.label.length === 0 || source.label.length > 256) { + throw new Error('inspector protocol: source label must contain 1 to 256 characters') + } + if (typeof source.timeOriginMs !== 'number' || !Number.isFinite(source.timeOriginMs)) { + throw new Error('inspector protocol: source timeOriginMs must be finite') + } + if (!Array.isArray(source.capabilities)) { + throw new Error('inspector protocol: source capabilities must be an array') + } + const capabilities = source.capabilities.map(parseSourceCapability) + const capabilityTypes = new Set() + for (const capability of capabilities) { + if (capabilityTypes.has(capability.type)) { + throw new Error(`inspector protocol: source declares ${capability.type} more than once`) + } + capabilityTypes.add(capability.type) + } + if (kind !== 'client' && capabilities.length > 0) { + throw new Error('inspector protocol: Host sources cannot declare Client capabilities') + } + const topics = value.topics.map((topic) => { + if (typeof topic !== 'string' || topic.length === 0 || topic.length > 128) { + throw new Error('inspector protocol: every source topic must contain 1 to 128 characters') + } + return topic + }) + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/open', + source: { + sourceId: sourceId(source.sourceId), + generation: generation(source.generation), + kind, + label: source.label, + timeOriginMs: source.timeOriginMs, + capabilities, + }, + topics, + } +} + +function parseSourceCapability(value: unknown): InspectorSourceCapability { + if (!isPlainObject(value) || typeof value.type !== 'string') { + throw new Error('inspector protocol: source capability must have a type') + } + switch (value.type) { + case 'client-runtime': return parseClientRuntimeCapability(value) + case 'client-console': return parseClientConsoleCapability(value) + case 'client-sources': return parseClientSourcesCapability(value) + default: throw new Error(`inspector protocol: unknown source capability ${JSON.stringify(value.type)}`) + } +} + +function parseRecordsFrame( + value: Record, + maxRecords: number, + replace: boolean, +): SourceReplaceFrame | SourceAppendFrame { + exactKeys( + value, + replace + ? ['v', 't', 'sourceId', 'generation', 'nextSequence', 'records'] + : ['v', 't', 'sourceId', 'generation', 'firstSequence', 'droppedBefore', 'records'], + replace ? 'source/replace frame' : 'source/append frame', + ) + if (!Array.isArray(value.records) || value.records.length > maxRecords) { + throw new Error(`inspector protocol: source batch exceeds ${String(maxRecords)} records`) + } + const records = value.records.map(parseRecord) + const common = { + v: INSPECTOR_PROTOCOL_VERSION, + sourceId: sourceId(value.sourceId), + generation: generation(value.generation), + records, + } as const + if (replace) { + return { + ...common, + t: 'source/replace', + nextSequence: natural(value.nextSequence, 'nextSequence'), + } + } + return { + ...common, + t: 'source/append', + firstSequence: natural(value.firstSequence, 'firstSequence'), + droppedBefore: natural(value.droppedBefore, 'droppedBefore'), + } +} + +function parseRecord(value: unknown): InspectorRecordInput { + if (!isPlainObject(value) + || typeof value.monotonicMs !== 'number' + || !Number.isFinite(value.monotonicMs) + || typeof value.topic !== 'string' + || value.topic.length === 0 + || value.topic.length > 128 + || !isJsonValue(value.payload)) { + throw new Error('inspector protocol: invalid observation record') + } + exactKeys(value, ['monotonicMs', 'topic', 'payload'], 'observation record') + return { monotonicMs: value.monotonicMs, topic: value.topic, payload: value.payload } +} + +function sourceId(value: unknown): InspectorSourceId { + if (typeof value !== 'string') throw new Error('inspector protocol: sourceId must be a string') + return inspectorId<'InspectorSourceId'>(value, 'sourceId') +} + +function generation(value: unknown): InspectorSourceGeneration { + if (typeof value !== 'string') throw new Error('inspector protocol: generation must be a string') + return inspectorId<'InspectorSourceGeneration'>(value, 'generation') +} + +function natural(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`inspector protocol: ${label} must be a non-negative safe integer`) + } + return value as number +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts new file mode 100644 index 0000000000..d4cb579616 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/command-codec.ts @@ -0,0 +1,134 @@ +/** Exact wire decoder for Client Runtime commands. */ + +import { isJsonValue, isPlainObject } from '../../../json.ts' +import { exactKeys, optionalBoolean, optionalNonNegativeNumber, optionalString, wireId } from '../../../validation.ts' +import type { ClientCallArgument, ClientRuntimeCallFunctionCommand, ClientRuntimeCommand } from './commands.ts' + +/** + * Parse and rebuild one Runtime command before it enters the Client realm. + * @param value - Untrusted command value. + * @returns The validated command union member. + */ +export function parseClientRuntimeCommand(value: unknown): ClientRuntimeCommand { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client Runtime command must have an op') + } + switch (value.op) { + case 'evaluate': { + exactKeys(value, [ + 'op', 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'returnByValue', + 'generatePreview', 'userGesture', 'awaitPromise', 'disableBreaks', 'replMode', + 'allowUnsafeEvalBlockedByCSP', 'timeoutMs', + ], 'evaluate command') + if (typeof value.expression !== 'string') throw new Error('inspector protocol: evaluate expression must be a string') + return { + op: 'evaluate', + expression: value.expression, + ...optionalString(value, 'objectGroup'), + ...optionalBoolean(value, 'includeCommandLineAPI'), + ...optionalBoolean(value, 'silent'), + ...optionalBoolean(value, 'returnByValue'), + ...optionalBoolean(value, 'generatePreview'), + ...optionalBoolean(value, 'userGesture'), + ...optionalBoolean(value, 'awaitPromise'), + ...optionalBoolean(value, 'disableBreaks'), + ...optionalBoolean(value, 'replMode'), + ...optionalBoolean(value, 'allowUnsafeEvalBlockedByCSP'), + ...optionalNonNegativeNumber(value, 'timeoutMs'), + } + } + case 'get-properties': + exactKeys(value, [ + 'op', 'handle', 'ownProperties', 'accessorPropertiesOnly', 'generatePreview', 'nonIndexedPropertiesOnly', + ], 'get-properties command') + return { + op: 'get-properties', + handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'), + ...optionalBoolean(value, 'ownProperties'), + ...optionalBoolean(value, 'accessorPropertiesOnly'), + ...optionalBoolean(value, 'generatePreview'), + ...optionalBoolean(value, 'nonIndexedPropertiesOnly'), + } + case 'call-function': + return parseCallFunction(value) + case 'await-promise': + exactKeys(value, ['op', 'promise', 'returnByValue', 'generatePreview'], 'await-promise command') + return { + op: 'await-promise', + promise: wireId<'ClientRemoteObjectHandle'>(value.promise, 'promise'), + ...optionalBoolean(value, 'returnByValue'), + ...optionalBoolean(value, 'generatePreview'), + } + case 'release-object': + exactKeys(value, ['op', 'handle'], 'release-object command') + return { + op: 'release-object', + handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'), + } + case 'release-object-group': + exactKeys(value, ['op', 'objectGroup'], 'release-object-group command') + if (typeof value.objectGroup !== 'string') throw new Error('inspector protocol: objectGroup must be a string') + return { op: 'release-object-group', objectGroup: value.objectGroup } + case 'global-lexical-scope-names': + exactKeys(value, ['op'], 'global-lexical-scope-names command') + return { op: 'global-lexical-scope-names' } + default: + throw new Error(`inspector protocol: unknown Client Runtime command ${JSON.stringify(value.op)}`) + } +} + +function parseCallFunction(value: Record): ClientRuntimeCallFunctionCommand { + exactKeys(value, [ + 'op', 'functionDeclaration', 'receiver', 'arguments', 'objectGroup', 'silent', 'returnByValue', + 'generatePreview', 'userGesture', 'awaitPromise', + ], 'call-function command') + if (typeof value.functionDeclaration !== 'string') { + throw new Error('inspector protocol: functionDeclaration must be a string') + } + let args: readonly ClientCallArgument[] | undefined + if (value.arguments !== undefined) { + if (!Array.isArray(value.arguments)) throw new Error('inspector protocol: call arguments must be an array') + args = value.arguments.map(parseCallArgument) + } + return { + op: 'call-function', + functionDeclaration: value.functionDeclaration, + ...(value.receiver === undefined + ? {} + : { receiver: wireId<'ClientRemoteObjectHandle'>(value.receiver, 'receiver') }), + ...(args === undefined ? {} : { arguments: args }), + ...optionalString(value, 'objectGroup'), + ...optionalBoolean(value, 'silent'), + ...optionalBoolean(value, 'returnByValue'), + ...optionalBoolean(value, 'generatePreview'), + ...optionalBoolean(value, 'userGesture'), + ...optionalBoolean(value, 'awaitPromise'), + } +} + +function parseCallArgument(value: unknown): ClientCallArgument { + if (!isPlainObject(value) || typeof value.kind !== 'string') { + throw new Error('inspector protocol: invalid Client Runtime call argument') + } + switch (value.kind) { + case 'value': + exactKeys(value, ['kind', 'value'], 'value call argument') + if (!isJsonValue(value.value)) throw new Error('inspector protocol: call argument value must be JSON') + return { kind: 'value', value: value.value } + case 'unserializable': + exactKeys(value, ['kind', 'value'], 'unserializable call argument') + if (typeof value.value !== 'string') throw new Error('inspector protocol: unserializable argument must be a string') + return { kind: 'unserializable', value: value.value } + case 'object': + exactKeys(value, ['kind', 'handle'], 'object call argument') + return { + kind: 'object', + handle: wireId<'ClientRemoteObjectHandle'>(value.handle, 'handle'), + } + case 'undefined': + exactKeys(value, ['kind'], 'undefined call argument') + return { kind: 'undefined' } + default: + throw new Error(`inspector protocol: unknown call argument ${JSON.stringify(value.kind)}`) + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts new file mode 100644 index 0000000000..4e16b58129 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/commands.ts @@ -0,0 +1,101 @@ +/** Closed command/result protocol for Runtime operations executed by a Client. */ + +import type { ClientRemoteObjectHandle } from '../../ids.ts' +import type { + RuntimeExceptionDetails, + RuntimeInternalPropertyDescriptor, + RuntimeCallArgument, + RuntimeAwaitPromiseRequest, + RuntimeCallFunctionRequest, + RuntimeCompletion, + RuntimeEvaluateRequest, + RuntimeGetPropertiesRequest, + RuntimePropertyDescriptor, + RuntimeRemoteObject, +} from '../../../cdp/index.ts' + +/** Runtime object serialized with one Client-session handle when retained. */ +export type ClientRuntimeRemoteObject = RuntimeRemoteObject + +/** Property descriptor whose retained values use Client-session handles. */ +export type ClientRuntimePropertyDescriptor = RuntimePropertyDescriptor + +/** Internal property descriptor whose retained values use Client-session handles. */ +export type ClientRuntimeInternalPropertyDescriptor = RuntimeInternalPropertyDescriptor + +/** Exception details whose retained value uses a Client-session handle. */ +export type ClientRuntimeExceptionDetails = RuntimeExceptionDetails + +/** One argument supplied to a function in the Client realm. */ +export type ClientCallArgument = RuntimeCallArgument + +/** Evaluate source text in the Client global execution context. */ +export interface ClientRuntimeEvaluateCommand extends RuntimeEvaluateRequest { + readonly op: 'evaluate' +} + +/** Enumerate properties of one retained Client object. */ +export interface ClientRuntimeGetPropertiesCommand extends RuntimeGetPropertiesRequest { + readonly op: 'get-properties' +} + +/** Invoke a function declaration with Client-local receivers and arguments. */ +export interface ClientRuntimeCallFunctionCommand extends RuntimeCallFunctionRequest { + readonly op: 'call-function' +} + +/** Await one retained Client promise. */ +export interface ClientRuntimeAwaitPromiseCommand extends RuntimeAwaitPromiseRequest { + readonly op: 'await-promise' +} + +/** Release one retained Client object. */ +export interface ClientRuntimeReleaseObjectCommand { + readonly op: 'release-object' + readonly handle: ClientRemoteObjectHandle +} + +/** Release every Client object retained under one DevTools object group. */ +export interface ClientRuntimeReleaseObjectGroupCommand { + readonly op: 'release-object-group' + readonly objectGroup: string +} + +/** Read names visible in the Client global lexical scope. */ +export interface ClientRuntimeGlobalLexicalScopeNamesCommand { + readonly op: 'global-lexical-scope-names' +} + +/** Closed command set implemented by the Client Runtime transport. */ +export type ClientRuntimeCommand = + | ClientRuntimeEvaluateCommand + | ClientRuntimeGetPropertiesCommand + | ClientRuntimeCallFunctionCommand + | ClientRuntimeAwaitPromiseCommand + | ClientRuntimeReleaseObjectCommand + | ClientRuntimeReleaseObjectGroupCommand + | ClientRuntimeGlobalLexicalScopeNamesCommand + +/** Shared result of evaluation, function calls, and promise awaiting. */ +export type ClientRuntimeCompletion = RuntimeCompletion + +/** Result discriminant mirrors the command and prevents cross-method settlement. */ +export type ClientRuntimeResult = + | { readonly op: 'evaluate'; readonly completion: ClientRuntimeCompletion } + | { + readonly op: 'get-properties' + readonly properties: readonly ClientRuntimePropertyDescriptor[] + readonly internalProperties?: readonly ClientRuntimeInternalPropertyDescriptor[] + readonly exceptionDetails?: ClientRuntimeExceptionDetails + } + | { readonly op: 'call-function'; readonly completion: ClientRuntimeCompletion } + | { readonly op: 'await-promise'; readonly completion: ClientRuntimeCompletion } + | { readonly op: 'release-object' } + | { readonly op: 'release-object-group' } + | { readonly op: 'global-lexical-scope-names'; readonly names: readonly string[] } + +/** Stable transport-level failures distinct from evaluated JavaScript exceptions. */ +export interface ClientRuntimeError { + readonly code: 'invalid-request' | 'object-not-found' | 'unsupported' | 'timeout' | 'result-too-large' | 'internal-error' + readonly message: string +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts new file mode 100644 index 0000000000..211acba8e2 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/console-frames.ts @@ -0,0 +1,147 @@ +/** Typed transport for Client Console sessions and events. */ + +import type { ClientRemoteObjectHandle, ClientRuntimeSessionId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts' +import { isPlainObject } from '../../../json.ts' +import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../../cdp/index.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import { + parseClientRuntimeExceptionDetails, + parseClientRuntimeRemoteObject, + parseClientRuntimeStackTrace, +} from './value-codec.ts' + +/** Source capability that permits Client Console event forwarding. */ +export interface ClientConsoleCapability { + readonly type: 'client-console' +} + +/** Worker request to start Console observation for one DevTools session. */ +export interface ClientConsoleEnableFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-console/enable' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId +} + +/** Worker request to stop Console observation for one DevTools session. */ +export interface ClientConsoleDisableFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-console/disable' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId +} + +/** Client Console event carrying objects retained for one DevTools session. */ +export interface ClientConsoleEventFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-console/event' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly event: RuntimeConsoleBackendEvent +} + +/** + * Parse the marker capability for Client Console forwarding. + * @param value - Untrusted capability declaration. + * @returns The validated marker capability. + */ +export function parseClientConsoleCapability(value: unknown): ClientConsoleCapability { + const record = exactObject(value, ['type'], 'Client Console capability') + if (record.type !== 'client-console') throw new Error('inspector protocol: invalid Client Console capability') + return { type: 'client-console' } +} + +/** + * Parse a Worker-to-Client Console lifecycle frame. + * @param value - Untrusted decoded frame. + * @returns A validated enable or disable frame. + */ +export function parseClientConsoleControlFrame( + value: Record, +): ClientConsoleEnableFrame | ClientConsoleDisableFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client Console control frame') + if (value.v !== INSPECTOR_PROTOCOL_VERSION + || (value.t !== 'client-console/enable' && value.t !== 'client-console/disable')) { + throw new Error('inspector protocol: invalid Client Console control frame') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: value.t, + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + } +} + +/** + * Parse one Client-to-Worker Console event. + * @param value - Untrusted decoded frame. + * @returns A validated Console event frame. + */ +export function parseClientConsoleEventFrame(value: Record): ClientConsoleEventFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'event'], 'Client Console event frame') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-console/event') { + throw new Error('inspector protocol: invalid Client Console event envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-console/event', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + event: parseEvent(value.event), + } +} + +function parseEvent(value: unknown): RuntimeConsoleBackendEvent { + if (!isPlainObject(value) || (value.type !== 'console-api' && value.type !== 'exception')) { + throw new Error('inspector protocol: invalid Client Console event') + } + if (value.type === 'console-api') { + exactKeys(value, ['type', 'event'], 'Client Console API event') + const event = exactObject(value.event, ['type', 'arguments', 'timestamp', 'contextId', 'stackTrace'], 'Console API event') + if (!CONSOLE_TYPES.has(event.type as RuntimeConsoleType) + || !Array.isArray(event.arguments) + || typeof event.timestamp !== 'number' + || !Number.isFinite(event.timestamp)) { + throw new Error('inspector protocol: invalid Console API event') + } + return { + type: 'console-api', + event: { + type: event.type as RuntimeConsoleType, + arguments: event.arguments.map(parseClientRuntimeRemoteObject), + timestamp: event.timestamp, + ...(event.contextId === undefined ? {} : { contextId: integer(event.contextId, 'contextId') }), + ...(event.stackTrace === undefined ? {} : { stackTrace: parseClientRuntimeStackTrace(event.stackTrace) }), + }, + } + } + exactKeys(value, ['type', 'event'], 'Client exception event') + const event = exactObject(value.event, ['timestamp', 'contextId', 'details'], 'Client exception event payload') + if (typeof event.timestamp !== 'number' || !Number.isFinite(event.timestamp)) { + throw new Error('inspector protocol: invalid Client exception timestamp') + } + return { + type: 'exception', + event: { + timestamp: event.timestamp, + ...(event.contextId === undefined ? {} : { contextId: integer(event.contextId, 'contextId') }), + details: parseClientRuntimeExceptionDetails(event.details), + }, + } +} + +function integer(value: unknown, label: string): number { + if (!Number.isSafeInteger(value)) throw new Error(`inspector protocol: ${label} must be an integer`) + return value as number +} + +const CONSOLE_TYPES = new Set([ + 'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear', + 'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts new file mode 100644 index 0000000000..a6ed8f620b --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts @@ -0,0 +1,147 @@ +/** Versioned envelopes for Worker-to-Client Runtime operations. */ + +import type { + ClientRuntimeRequestId, + ClientRuntimeSessionId, + InspectorSourceGeneration, + InspectorSourceId, +} from '../../ids.ts' +import { isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import { parseClientRuntimeCommand } from './command-codec.ts' +import { parseClientRuntimeResult } from './value-codec.ts' +import type { ClientRuntimeCommand, ClientRuntimeError, ClientRuntimeResult } from './commands.ts' + +/** Source capability that permits synthetic Runtime execution contexts. */ +export interface ClientRuntimeCapability { + readonly type: 'client-runtime' + readonly origin: string +} + +/** Worker request for one operation in a specific source generation and DevTools session. */ +export interface ClientRuntimeRequestFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/request' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly requestId: ClientRuntimeRequestId + readonly command: ClientRuntimeCommand +} + +/** Client response to one typed Runtime request. */ +export interface ClientRuntimeResponseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/response' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly requestId: ClientRuntimeRequestId + readonly outcome: + | { readonly ok: true; readonly result: ClientRuntimeResult } + | { readonly ok: false; readonly error: ClientRuntimeError } +} + +/** One-way cleanup when a DevTools connection or its Runtime domain closes. */ +export interface ClientRuntimeSessionClosedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/session-closed' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId +} + +/** + * Parse and rebuild a Client Runtime capability. + * @param value - Untrusted capability declaration. + * @returns The validated capability. + */ +export function parseClientRuntimeCapability(value: unknown): ClientRuntimeCapability { + const record = exactObject(value, ['type', 'origin'], 'Client Runtime capability') + if (record.type !== 'client-runtime' || typeof record.origin !== 'string' || record.origin.length > 2_048) { + throw new Error('inspector protocol: invalid Client Runtime capability') + } + return { type: 'client-runtime', origin: record.origin } +} + +/** + * Parse and rebuild one Worker-to-Client Runtime request. + * @param value - Untrusted request frame. + * @returns The validated request frame. + */ +export function parseClientRuntimeRequestFrame(value: Record): ClientRuntimeRequestFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'command'], 'Client Runtime request') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/request') { + throw new Error('inspector protocol: invalid Client Runtime request envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/request', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), + command: parseClientRuntimeCommand(value.command), + } +} + +/** + * Parse and rebuild one Client-to-Worker Runtime response. + * @param value - Untrusted response frame. + * @returns The validated response frame. + */ +export function parseClientRuntimeResponseFrame(value: Record): ClientRuntimeResponseFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'outcome'], 'Client Runtime response') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/response') { + throw new Error('inspector protocol: invalid Client Runtime response envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/response', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), + outcome: parseOutcome(value.outcome), + } +} + +/** + * Parse and rebuild one Runtime-session cleanup notification. + * @param value - Untrusted cleanup frame. + * @returns The validated cleanup frame. + */ +export function parseClientRuntimeSessionClosedFrame(value: Record): ClientRuntimeSessionClosedFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client Runtime session close') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/session-closed') { + throw new Error('inspector protocol: invalid Client Runtime session close envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/session-closed', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + } +} + +function parseOutcome(value: unknown): ClientRuntimeResponseFrame['outcome'] { + if (!isPlainObject(value) || typeof value.ok !== 'boolean') { + throw new Error('inspector protocol: invalid Client Runtime outcome') + } + if (value.ok) { + exactKeys(value, ['ok', 'result'], 'successful Client Runtime outcome') + return { ok: true, result: parseClientRuntimeResult(value.result) } + } + exactKeys(value, ['ok', 'error'], 'failed Client Runtime outcome') + const error = exactObject(value.error, ['code', 'message'], 'Client Runtime error') + if (!ERROR_CODES.has(error.code as ClientRuntimeError['code']) || typeof error.message !== 'string') { + throw new Error('inspector protocol: invalid Client Runtime error') + } + return { ok: false, error: { code: error.code as ClientRuntimeError['code'], message: error.message } } +} + +const ERROR_CODES = new Set([ + 'invalid-request', 'object-not-found', 'unsupported', 'timeout', 'result-too-large', 'internal-error', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts new file mode 100644 index 0000000000..b0b569ded4 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/index.ts @@ -0,0 +1,5 @@ +/** Public types and boundary decoders for the Client Runtime wire protocol. */ + +export * from './commands.ts' +export * from './console-frames.ts' +export * from './frames.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts new file mode 100644 index 0000000000..da9dcc0bf9 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/value-codec.ts @@ -0,0 +1,334 @@ +/** Exact wire decoder for Client Runtime results and RemoteObject data. */ + +import { isJsonValue, isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, optionalBoolean, optionalString, wireId } from '../../../validation.ts' +import { parseInspectorObjectReference } from '../../../cordis/object-reference.ts' +import type { + RuntimeCallFrame, + RuntimeObjectPreview, + RuntimePropertyPreview, + RuntimeRemoteObjectDescriptor, + RuntimeRemoteObjectSubtype, + RuntimeRemoteObjectType, + RuntimeStackTrace, +} from '../../../cdp/index.ts' +import type { + ClientRuntimeCompletion, + ClientRuntimeExceptionDetails, + ClientRuntimeInternalPropertyDescriptor, + ClientRuntimePropertyDescriptor, + ClientRuntimeRemoteObject, + ClientRuntimeResult, +} from './commands.ts' + +/** + * Parse and rebuild one successful Client Runtime result. + * @param value - Untrusted result value. + * @returns The validated result union member. + */ +export function parseClientRuntimeResult(value: unknown): ClientRuntimeResult { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client Runtime result must have an op') + } + switch (value.op) { + case 'evaluate': + case 'call-function': + case 'await-promise': + exactKeys(value, ['op', 'completion'], `${value.op} result`) + return { op: value.op, completion: parseCompletion(value.completion) } + case 'get-properties': { + exactKeys(value, ['op', 'properties', 'internalProperties', 'exceptionDetails'], 'get-properties result') + if (!Array.isArray(value.properties)) throw new Error('inspector protocol: properties must be an array') + const internal = value.internalProperties + if (internal !== undefined && !Array.isArray(internal)) { + throw new Error('inspector protocol: internalProperties must be an array') + } + return { + op: 'get-properties', + properties: value.properties.map(parsePropertyDescriptor), + ...(internal === undefined ? {} : { internalProperties: internal.map(parseInternalPropertyDescriptor) }), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: parseClientRuntimeExceptionDetails(value.exceptionDetails) }), + } + } + case 'release-object': + case 'release-object-group': + exactKeys(value, ['op'], `${value.op} result`) + return { op: value.op } + case 'global-lexical-scope-names': + exactKeys(value, ['op', 'names'], 'global-lexical-scope-names result') + if (!Array.isArray(value.names) || !value.names.every(name => typeof name === 'string')) { + throw new Error('inspector protocol: lexical scope names must be strings') + } + return { op: 'global-lexical-scope-names', names: value.names } + default: + throw new Error(`inspector protocol: unknown Client Runtime result ${JSON.stringify(value.op)}`) + } +} + +function parseCompletion(value: unknown): ClientRuntimeCompletion { + const record = exactObject(value, ['result', 'exceptionDetails'], 'Client Runtime completion') + return { + result: parseClientRuntimeRemoteObject(record.result), + ...(record.exceptionDetails === undefined + ? {} + : { exceptionDetails: parseClientRuntimeExceptionDetails(record.exceptionDetails) }), + } +} + +/** + * Decode one Client Runtime object carrying an optional session-local handle. + * @param value - Untrusted wire value. + * @returns The validated realm-neutral object value. + */ +export function parseClientRuntimeRemoteObject(value: unknown): ClientRuntimeRemoteObject { + const record = exactObject(value, ['descriptor', 'object', 'semanticReference'], 'Client Runtime object') + const descriptor = parseRemoteObjectDescriptor(record.descriptor) + const object = record.object === undefined + ? undefined + : exactObject(record.object, ['handle'], 'Client Runtime object reference') + const remote: ClientRuntimeRemoteObject = { + descriptor, + ...(object === undefined + ? {} + : { object: { handle: wireId<'ClientRemoteObjectHandle'>(object.handle, 'handle') } }), + ...(record.semanticReference === undefined + ? {} + : { semanticReference: parseInspectorObjectReference(record.semanticReference) }), + } + validateRemoteObject(remote) + return remote +} + +function parseRemoteObjectDescriptor(value: unknown): RuntimeRemoteObjectDescriptor { + const record = exactObject(value, [ + 'type', 'subtype', 'className', 'value', 'unserializableValue', 'description', 'preview', + ], 'Runtime object descriptor') + if (!REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType)) { + throw new Error('inspector protocol: invalid Client RemoteObject type') + } + if (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype)) { + throw new Error('inspector protocol: invalid Client RemoteObject subtype') + } + if (record.value !== undefined && !isJsonValue(record.value)) { + throw new Error('inspector protocol: Client RemoteObject value must be JSON') + } + return { + type: record.type as RuntimeRemoteObjectType, + ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }), + ...optionalString(record, 'className'), + ...(record.value === undefined ? {} : { value: record.value }), + ...optionalString(record, 'unserializableValue'), + ...optionalString(record, 'description'), + ...(record.preview === undefined ? {} : { preview: parseObjectPreview(record.preview) }), + } +} + +function parseObjectPreview(value: unknown): RuntimeObjectPreview { + const record = exactObject(value, ['type', 'subtype', 'description', 'overflow', 'properties'], 'object preview') + if (!REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType) + || (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype)) + || typeof record.overflow !== 'boolean' + || !Array.isArray(record.properties)) { + throw new Error('inspector protocol: invalid object preview') + } + return { + type: record.type as RuntimeRemoteObjectType, + ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }), + ...optionalString(record, 'description'), + overflow: record.overflow, + properties: record.properties.map(parsePropertyPreview), + } +} + +function parsePropertyPreview(value: unknown): RuntimePropertyPreview { + const record = exactObject(value, ['name', 'type', 'value', 'valuePreview', 'subtype'], 'property preview') + if (typeof record.name !== 'string' + || (record.type !== 'accessor' && !REMOTE_TYPES.has(record.type as RuntimeRemoteObjectType)) + || (record.subtype !== undefined && !REMOTE_SUBTYPES.has(record.subtype as RuntimeRemoteObjectSubtype))) { + throw new Error('inspector protocol: invalid property preview') + } + return { + name: record.name, + type: record.type as RuntimePropertyPreview['type'], + ...optionalString(record, 'value'), + ...(record.valuePreview === undefined ? {} : { valuePreview: parseObjectPreview(record.valuePreview) }), + ...(record.subtype === undefined ? {} : { subtype: record.subtype as RuntimeRemoteObjectSubtype }), + } +} + +function parsePropertyDescriptor(value: unknown): ClientRuntimePropertyDescriptor { + const record = exactObject(value, [ + 'name', 'value', 'writable', 'get', 'set', 'configurable', 'enumerable', 'wasThrown', 'isOwn', 'symbol', + ], 'property descriptor') + if (typeof record.name !== 'string' || typeof record.configurable !== 'boolean' || typeof record.enumerable !== 'boolean') { + throw new Error('inspector protocol: invalid property descriptor') + } + const dataDescriptor = record.value !== undefined || record.writable !== undefined + const accessorDescriptor = record.get !== undefined || record.set !== undefined + if (dataDescriptor && accessorDescriptor) { + throw new Error('inspector protocol: property descriptor mixes data and accessor fields') + } + return { + name: record.name, + ...(record.value === undefined ? {} : { value: parseClientRuntimeRemoteObject(record.value) }), + ...optionalBoolean(record, 'writable'), + ...(record.get === undefined ? {} : { get: parseClientRuntimeRemoteObject(record.get) }), + ...(record.set === undefined ? {} : { set: parseClientRuntimeRemoteObject(record.set) }), + configurable: record.configurable, + enumerable: record.enumerable, + ...optionalBoolean(record, 'wasThrown'), + ...optionalBoolean(record, 'isOwn'), + ...(record.symbol === undefined ? {} : { symbol: parseClientRuntimeRemoteObject(record.symbol) }), + } +} + +function parseInternalPropertyDescriptor(value: unknown): ClientRuntimeInternalPropertyDescriptor { + const record = exactObject(value, ['name', 'value'], 'internal property descriptor') + if (typeof record.name !== 'string') throw new Error('inspector protocol: invalid internal property descriptor') + return { + name: record.name, + ...(record.value === undefined ? {} : { value: parseClientRuntimeRemoteObject(record.value) }), + } +} + +/** + * Decode Client exception details used by command results and events. + * @param value - Untrusted wire value. + * @returns Validated exception details. + */ +export function parseClientRuntimeExceptionDetails(value: unknown): ClientRuntimeExceptionDetails { + const record = exactObject(value, [ + 'text', 'lineNumber', 'columnNumber', 'url', 'stackTrace', 'exception', + ], 'exception details') + if (typeof record.text !== 'string' + || !Number.isSafeInteger(record.lineNumber) + || (record.lineNumber as number) < 0 + || !Number.isSafeInteger(record.columnNumber) + || (record.columnNumber as number) < 0) { + throw new Error('inspector protocol: invalid exception details') + } + return { + text: record.text, + lineNumber: record.lineNumber as number, + columnNumber: record.columnNumber as number, + ...optionalString(record, 'url'), + ...(record.stackTrace === undefined ? {} : { stackTrace: parseClientRuntimeStackTrace(record.stackTrace) }), + ...(record.exception === undefined ? {} : { exception: parseClientRuntimeRemoteObject(record.exception) }), + } +} + +/** + * Decode a stack trace carried by a Client Runtime or Console frame. + * @param value - Untrusted stack-trace value. + * @returns The validated realm-neutral stack trace. + */ +export function parseClientRuntimeStackTrace(value: unknown): RuntimeStackTrace { + const record = exactObject(value, ['description', 'callFrames', 'parent'], 'stack trace') + if (!Array.isArray(record.callFrames)) throw new Error('inspector protocol: stack callFrames must be an array') + return { + ...optionalString(record, 'description'), + callFrames: record.callFrames.map(parseCallFrame), + ...(record.parent === undefined ? {} : { parent: parseClientRuntimeStackTrace(record.parent) }), + } +} + +function parseCallFrame(value: unknown): RuntimeCallFrame { + const record = exactObject(value, ['functionName', 'scriptKey', 'url', 'lineNumber', 'columnNumber'], 'stack call frame') + if (typeof record.functionName !== 'string' + || typeof record.url !== 'string' + || !Number.isSafeInteger(record.lineNumber) + || !Number.isSafeInteger(record.columnNumber)) { + throw new Error('inspector protocol: invalid stack call frame') + } + return { + functionName: record.functionName, + ...(record.scriptKey === undefined ? {} : { scriptKey: wireId<'RuntimeScriptKey'>(record.scriptKey, 'scriptKey') }), + url: record.url, + lineNumber: record.lineNumber as number, + columnNumber: record.columnNumber as number, + } +} + +const REMOTE_TYPES = new Set([ + 'object', 'function', 'undefined', 'string', 'number', 'boolean', 'symbol', 'bigint', +]) + +const REMOTE_SUBTYPES = new Set([ + 'array', 'null', 'node', 'regexp', 'date', 'map', 'set', 'weakmap', 'weakset', 'iterator', 'generator', + 'error', 'proxy', 'promise', 'typedarray', 'arraybuffer', 'dataview', 'webassemblymemory', 'wasmvalue', +]) + +function validateRemoteObject(value: ClientRuntimeRemoteObject): void { + if (value.semanticReference !== undefined && value.object === undefined) { + throw new Error('inspector protocol: semanticReference requires a retained Client object') + } + const descriptor = value.descriptor + if (descriptor.subtype !== undefined && descriptor.type !== 'object') { + throw new Error('inspector protocol: only object RemoteObjects may have a subtype') + } + if (descriptor.preview !== undefined && descriptor.type !== 'object') { + throw new Error('inspector protocol: only object RemoteObjects may have a preview') + } + const hasValue = descriptor.value !== undefined + const hasUnserializableValue = descriptor.unserializableValue !== undefined + const hasObject = value.object !== undefined + switch (descriptor.type) { + case 'undefined': + requireRepresentations(descriptor.type, hasValue, hasUnserializableValue, hasObject, false, false, false) + return + case 'string': + requireRepresentations(descriptor.type, typeof descriptor.value === 'string', hasUnserializableValue, hasObject, true, false, false) + return + case 'boolean': + requireRepresentations(descriptor.type, typeof descriptor.value === 'boolean', hasUnserializableValue, hasObject, true, false, false) + return + case 'number': { + const finite = typeof descriptor.value === 'number' + && Number.isFinite(descriptor.value) + && !Object.is(descriptor.value, -0) + const special = descriptor.unserializableValue === 'NaN' + || descriptor.unserializableValue === 'Infinity' + || descriptor.unserializableValue === '-Infinity' + || descriptor.unserializableValue === '-0' + if (hasObject || finite === special) throw new Error('inspector protocol: invalid number RemoteObject representation') + return + } + case 'bigint': + if (hasValue || hasObject || !/^-?(?:0|[1-9]\d*)n$/u.test(descriptor.unserializableValue ?? '')) { + throw new Error('inspector protocol: invalid bigint RemoteObject representation') + } + return + case 'symbol': + case 'function': + requireRepresentations(descriptor.type, hasValue, hasUnserializableValue, hasObject, false, false, true) + return + case 'object': + if (descriptor.subtype === 'null') { + if (descriptor.value !== null || hasObject || hasUnserializableValue) { + throw new Error('inspector protocol: invalid null RemoteObject representation') + } + return + } + if (hasUnserializableValue || hasValue === hasObject) { + throw new Error('inspector protocol: object RemoteObject needs exactly one value or backend object') + } + } +} + +function requireRepresentations( + type: RuntimeRemoteObjectType, + hasValue: boolean, + hasUnserializableValue: boolean, + hasObject: boolean, + expectedValue: boolean, + expectedUnserializableValue: boolean, + expectedObject: boolean, +): void { + if (hasValue !== expectedValue + || hasUnserializableValue !== expectedUnserializableValue + || hasObject !== expectedObject) { + throw new Error(`inspector protocol: invalid ${type} RemoteObject representation`) + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts new file mode 100644 index 0000000000..a774830cff --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/codec.ts @@ -0,0 +1,127 @@ +/** Exact decoders for Client source catalog operations and values. */ + +import { isPlainObject } from '../../../json.ts' +import type { RuntimeScript } from '../../../cdp/index.ts' +import { exactKeys, exactObject, optionalBoolean, optionalString, wireId } from '../../../validation.ts' +import type { + ClientScriptDescriptor, + ClientSourceCommand, + ClientSourceContentKind, + ClientSourceResult, +} from './commands.ts' + +/** + * Parse one Worker-to-Client source command. + * @param value - Untrusted decoded command. + * @returns The validated command. + */ +export function parseClientSourceCommand(value: unknown): ClientSourceCommand { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client source command must have an op') + } + if (value.op === 'list-scripts') { + exactKeys(value, ['op'], 'Client source list command') + return { op: 'list-scripts' } + } + if (value.op !== 'get-content-chunk') { + throw new Error(`inspector protocol: unknown Client source command ${JSON.stringify(value.op)}`) + } + exactKeys(value, ['op', 'scriptKey', 'content', 'offset', 'maxBytes'], 'Client source chunk command') + return { + op: 'get-content-chunk', + scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'), + content: contentKind(value.content), + offset: natural(value.offset, 'offset', true), + maxBytes: natural(value.maxBytes, 'maxBytes', false), + } +} + +/** + * Parse one successful Client source result. + * @param value - Untrusted decoded result. + * @returns The validated result. + */ +export function parseClientSourceResult(value: unknown): ClientSourceResult { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: Client source result must have an op') + } + if (value.op === 'list-scripts') { + exactKeys(value, ['op', 'scripts'], 'Client source list result') + if (!Array.isArray(value.scripts)) throw new Error('inspector protocol: Client source scripts must be an array') + return { op: 'list-scripts', scripts: value.scripts.map(parseScript) } + } + if (value.op !== 'get-content-chunk') { + throw new Error(`inspector protocol: unknown Client source result ${JSON.stringify(value.op)}`) + } + if (value.available === false) { + exactKeys(value, ['op', 'scriptKey', 'content', 'available'], 'unavailable Client source chunk') + return { + op: 'get-content-chunk', + scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'), + content: contentKind(value.content), + available: false, + } + } + exactKeys( + value, + ['op', 'scriptKey', 'content', 'available', 'offset', 'nextOffset', 'data', 'eof'], + 'Client source chunk result', + ) + if (value.available !== true || typeof value.data !== 'string' || typeof value.eof !== 'boolean') { + throw new Error('inspector protocol: invalid Client source chunk result') + } + const offset = natural(value.offset, 'offset', true) + const nextOffset = natural(value.nextOffset, 'nextOffset', true) + if (nextOffset < offset || !BASE64.test(value.data)) { + throw new Error('inspector protocol: invalid Client source chunk data') + } + return { + op: 'get-content-chunk', + scriptKey: wireId<'RuntimeScriptKey'>(value.scriptKey, 'scriptKey'), + content: contentKind(value.content), + available: true, + offset, + nextOffset, + data: value.data, + eof: value.eof, + } +} + +function parseScript(value: unknown): ClientScriptDescriptor { + const record = exactObject(value, [ + 'scriptKey', 'url', 'hash', 'buildId', 'sourceMapUrl', 'startLine', 'startColumn', 'endLine', 'endColumn', + 'isModule', 'length', + ], 'Client script descriptor') + if (typeof record.url !== 'string' || record.url.length > 8_192 || typeof record.hash !== 'string') { + throw new Error('inspector protocol: invalid Client script identity') + } + return { + scriptKey: wireId<'RuntimeScriptKey'>(record.scriptKey, 'scriptKey'), + url: record.url, + hash: record.hash, + ...optionalString(record, 'buildId'), + ...optionalString(record, 'sourceMapUrl'), + startLine: natural(record.startLine, 'startLine', true), + startColumn: natural(record.startColumn, 'startColumn', true), + endLine: natural(record.endLine, 'endLine', true), + endColumn: natural(record.endColumn, 'endColumn', true), + ...optionalBoolean(record, 'isModule'), + ...(record.length === undefined ? {} : { length: natural(record.length, 'length', true) }), + } satisfies Omit +} + +function contentKind(value: unknown): ClientSourceContentKind { + if (value !== 'source' && value !== 'source-map') { + throw new Error('inspector protocol: invalid Client source content kind') + } + return value +} + +function natural(value: unknown, label: string, zero: boolean): number { + if (!Number.isSafeInteger(value) || (value as number) < (zero ? 0 : 1)) { + throw new Error(`inspector protocol: ${label} must be ${zero ? 'a non-negative' : 'a positive'} integer`) + } + return value as number +} + +const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/u diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts new file mode 100644 index 0000000000..bb39bfc0d1 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/commands.ts @@ -0,0 +1,47 @@ +/** Operations and values exchanged with a Client realm's read-only source catalog. */ + +import type { RuntimeScriptKey } from '../../../cdp/ids.ts' +import type { RuntimeScript } from '../../../cdp/index.ts' + +/** Script metadata that excludes the Worker-owned execution-context id. */ +export type ClientScriptDescriptor = Omit + +/** Content stored for one Client script. */ +export type ClientSourceContentKind = 'source' | 'source-map' + +/** Read-only operation accepted by the Client source catalog. */ +export type ClientSourceCommand = + | { readonly op: 'list-scripts' } + | { + readonly op: 'get-content-chunk' + readonly scriptKey: RuntimeScriptKey + readonly content: ClientSourceContentKind + readonly offset: number + readonly maxBytes: number + } + +/** Successful result of one Client source operation. */ +export type ClientSourceResult = + | { readonly op: 'list-scripts'; readonly scripts: readonly ClientScriptDescriptor[] } + | { + readonly op: 'get-content-chunk' + readonly scriptKey: RuntimeScriptKey + readonly content: ClientSourceContentKind + readonly available: false + } + | { + readonly op: 'get-content-chunk' + readonly scriptKey: RuntimeScriptKey + readonly content: ClientSourceContentKind + readonly available: true + readonly offset: number + readonly nextOffset: number + readonly data: string + readonly eof: boolean + } + +/** Deliberate failure returned by the Client source catalog. */ +export interface ClientSourceError { + readonly code: 'invalid-request' | 'script-not-found' | 'load-failed' | 'result-too-large' | 'internal-error' + readonly message: string +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts new file mode 100644 index 0000000000..9feb4bcec6 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/frames.ts @@ -0,0 +1,143 @@ +/** Versioned envelopes for Client source catalog operations. */ + +import type { + ClientSourceRequestId, + ClientSourceSessionId, + InspectorSourceGeneration, + InspectorSourceId, +} from '../../ids.ts' +import { isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import { parseClientSourceCommand, parseClientSourceResult } from './codec.ts' +import type { ClientSourceCommand, ClientSourceError, ClientSourceResult } from './commands.ts' + +/** Source capability that permits read-only Client script discovery. */ +export interface ClientSourcesCapability { + readonly type: 'client-sources' +} + +/** Worker request for one operation in a Client source catalog. */ +export interface ClientSourceRequestFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-sources/request' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientSourceSessionId + readonly requestId: ClientSourceRequestId + readonly command: ClientSourceCommand +} + +/** Client response to one source catalog operation. */ +export interface ClientSourceResponseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-sources/response' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientSourceSessionId + readonly requestId: ClientSourceRequestId + readonly outcome: + | { readonly ok: true; readonly result: ClientSourceResult } + | { readonly ok: false; readonly error: ClientSourceError } +} + +/** One-way cleanup for in-flight operations owned by a closed DevTools session. */ +export interface ClientSourceSessionClosedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-sources/session-closed' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientSourceSessionId +} + +/** + * Parse the marker capability for a Client source catalog. + * @param value - Untrusted capability declaration. + * @returns The validated marker capability. + */ +export function parseClientSourcesCapability(value: unknown): ClientSourcesCapability { + const record = exactObject(value, ['type'], 'Client Sources capability') + if (record.type !== 'client-sources') throw new Error('inspector protocol: invalid Client Sources capability') + return { type: 'client-sources' } +} + +/** + * Parse one Worker-to-Client source request. + * @param value - Untrusted decoded request. + * @returns The validated request frame. + */ +export function parseClientSourceRequestFrame(value: Record): ClientSourceRequestFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'command'], 'Client source request') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-sources/request') { + throw new Error('inspector protocol: invalid Client source request envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/request', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientSourceSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientSourceRequestId'>(value.requestId, 'requestId'), + command: parseClientSourceCommand(value.command), + } +} + +/** + * Parse one Client-to-Worker source response. + * @param value - Untrusted decoded response. + * @returns The validated response frame. + */ +export function parseClientSourceResponseFrame(value: Record): ClientSourceResponseFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId', 'outcome'], 'Client source response') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-sources/response') { + throw new Error('inspector protocol: invalid Client source response envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/response', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientSourceSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientSourceRequestId'>(value.requestId, 'requestId'), + outcome: parseOutcome(value.outcome), + } +} + +/** + * Parse one Client source-session cleanup notification. + * @param value - Untrusted decoded notification. + * @returns The validated cleanup frame. + */ +export function parseClientSourceSessionClosedFrame(value: Record): ClientSourceSessionClosedFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId'], 'Client source session close') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-sources/session-closed') { + throw new Error('inspector protocol: invalid Client source session close envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/session-closed', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientSourceSessionId'>(value.sessionId, 'sessionId'), + } +} + +function parseOutcome(value: unknown): ClientSourceResponseFrame['outcome'] { + if (!isPlainObject(value) || typeof value.ok !== 'boolean') { + throw new Error('inspector protocol: invalid Client source outcome') + } + if (value.ok) { + exactKeys(value, ['ok', 'result'], 'successful Client source outcome') + return { ok: true, result: parseClientSourceResult(value.result) } + } + exactKeys(value, ['ok', 'error'], 'failed Client source outcome') + const error = exactObject(value.error, ['code', 'message'], 'Client source error') + if (!ERROR_CODES.has(error.code as ClientSourceError['code']) || typeof error.message !== 'string') { + throw new Error('inspector protocol: invalid Client source error') + } + return { ok: false, error: { code: error.code as ClientSourceError['code'], message: error.message } } +} + +const ERROR_CODES = new Set([ + 'invalid-request', 'script-not-found', 'load-failed', 'result-too-large', 'internal-error', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts b/packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts new file mode 100644 index 0000000000..29831039eb --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/sources/index.ts @@ -0,0 +1,5 @@ +/** Public types and decoders for the Client source catalog protocol. */ + +export * from './codec.ts' +export * from './commands.ts' +export * from './frames.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/publisher.ts b/packages/experimental/inspector/src/shared/bridge/publisher.ts new file mode 100644 index 0000000000..a383f587c9 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/publisher.ts @@ -0,0 +1,24 @@ +/** Source-side interfaces shared by MessagePort and WebSocket bridge implementations. */ + +import type { InspectorJsonValue } from '../json.ts' +import type { InspectorQueryRequester } from './messages/query/commands.ts' + +/** Transport-independent observation publisher. */ +export interface InspectorPublisher { + /** Publish one validated observation. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +} + +/** Publisher that also retains the latest value of stateful observation topics. */ +export interface InspectorStatePublisher extends InspectorPublisher { + /** + * Replace one topic's retained state and publish the replacement. + * @param topic - Domain-owned state topic. + * @param payload - Latest JSON state, replayed after source resynchronization. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +} + +/** Shared capabilities exposed above a Host MessagePort or Client WebSocket carrier. */ +export interface InspectorConnection extends InspectorStatePublisher, InspectorQueryRequester {} diff --git a/packages/experimental/inspector/src/shared/bridge/rpc.ts b/packages/experimental/inspector/src/shared/bridge/rpc.ts new file mode 100644 index 0000000000..24bfd3827a --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/rpc.ts @@ -0,0 +1,182 @@ +/** Shared Host/Client owner of correlated non-CDP query requests. */ + +import { inspectorId, type InspectorSourceGeneration, type InspectorSourceId } from './ids.ts' +import { jsonByteLength, type InspectorJsonValue } from '../json.ts' +import { INSPECTOR_PROTOCOL_VERSION } from './version.ts' +import type { + InspectorQuery, + InspectorQueryError, + InspectorQueryRequester, + InspectorQueryResult, + InspectorQueryResultFor, +} from './messages/query/commands.ts' +import { isInspectorQueryResponseEnvelope, parseInspectorQueryResponseFrame } from './messages/query/codec.ts' +import type { InspectorQueryRequestFrame, InspectorQueryRequestId } from './messages/query/frames.ts' + +/** Active carrier write used by the shared query owner. */ +export interface InspectorQuerySender { + /** + * Send one validated query request frame. + * @param frame - Request belonging to the active source generation. + */ + send(frame: InspectorQueryRequestFrame): void +} + +/** Bounds applied by one Host or Client query connection. */ +export interface InspectorQueryConnectionOptions { + readonly timeoutMs: number + readonly maxFrameBytes: number +} + +interface PendingQuery { + readonly op: string + readonly resolve: (result: InspectorQueryResult) => void + readonly reject: (error: Error) => void + readonly timer: ReturnType +} + +interface QueryGeneration { + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sender: InspectorQuerySender +} + +/** Failure deliberately returned by the Worker query handler. */ +export class InspectorQueryRemoteError extends Error { + constructor(readonly code: InspectorQueryError['code'], message: string) { + super(message) + } +} + +/** Correlates requests for one reconnecting Host or Client source. */ +export class InspectorQueryConnection implements InspectorQueryRequester { + private readonly pending = new Map() + private active: QueryGeneration | undefined + private nextRequestId = 0 + private closed = false + + constructor(private readonly options: InspectorQueryConnectionOptions) {} + + /** + * Admit the source generation acknowledged by the Worker. + * @param sourceId - Stable source identity. + * @param generation - Newly accepted transport generation. + * @param sender - Carrier writer valid for that generation. + */ + connect(sourceId: InspectorSourceId, generation: InspectorSourceGeneration, sender: InspectorQuerySender): void { + if (this.closed) throw new Error('inspector query connection is closed') + this.disconnect('Inspector source generation replaced') + this.active = { sourceId, generation, sender } + } + + /** + * Execute a query against the currently accepted source generation. + * @param query - Closed typed query command. + * @returns The result with the same operation discriminant. + */ + request(query: Query): Promise> { + const active = this.active + if (this.closed || active === undefined) { + return Promise.reject(new Error('Inspector query transport is not connected')) + } + const requestId = inspectorId<'InspectorQueryRequestId'>(`query-${String(++this.nextRequestId)}`, 'requestId') + const frame: InspectorQueryRequestFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/request', + sourceId: active.sourceId, + generation: active.generation, + requestId, + query, + } + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.options.maxFrameBytes) { + return Promise.reject(new Error(`Inspector query request exceeds ${String(this.options.maxFrameBytes)} bytes`)) + } + const result = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId) + reject(new Error(`Inspector query ${query.op} timed out after ${String(this.options.timeoutMs)}ms`)) + }, this.options.timeoutMs) + this.pending.set(requestId, { op: query.op, resolve, reject, timer }) + try { + active.sender.send(frame) + } catch (error) { + this.rejectPending(requestId, renderError(error)) + } + }) + return result as Promise> + } + + /** + * Consume a decoded carrier value when it is a query response. + * @param value - Untrusted Worker-to-source value. + * @returns Whether the value belonged to the query protocol. + */ + receive(value: unknown): boolean { + if (!isInspectorQueryResponseEnvelope(value)) return false + let frame + try { + frame = parseInspectorQueryResponseFrame(value) + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.options.maxFrameBytes) { + throw new Error(`inspector protocol: query response exceeds ${String(this.options.maxFrameBytes)} bytes`) + } + } catch (error) { + this.disconnect(`Invalid Inspector query response: ${renderError(error).message}`) + throw error + } + const pending = this.pending.get(frame.requestId) + if (pending === undefined) return true + const active = this.active + if (active === undefined || frame.sourceId !== active.sourceId || frame.generation !== active.generation) { + this.rejectPending(frame.requestId, new Error('Inspector query response source generation does not match')) + return true + } + if (!frame.outcome.ok) { + this.rejectPending(frame.requestId, new InspectorQueryRemoteError( + frame.outcome.error.code, + frame.outcome.error.message, + )) + return true + } + if (frame.outcome.result.op !== pending.op) { + this.rejectPending(frame.requestId, new Error( + `Inspector query response op ${frame.outcome.result.op} does not match ${pending.op}`, + )) + return true + } + clearTimeout(pending.timer) + this.pending.delete(frame.requestId) + pending.resolve(frame.outcome.result) + return true + } + + /** + * Reject active requests while permitting a later source generation. + * @param reason - Failure reported to every pending caller. + */ + disconnect(reason: string): void { + this.active = undefined + for (const requestId of [...this.pending.keys()]) this.rejectPending(requestId, new Error(reason)) + } + + /** + * Permanently reject requests and prevent later reconnection. + * @param reason - Failure reported to every pending caller. + */ + close(reason = 'Inspector query connection closed'): void { + if (this.closed) return + this.closed = true + this.disconnect(reason) + } + + private rejectPending(requestId: InspectorQueryRequestId, error: Error): void { + const pending = this.pending.get(requestId) + if (pending === undefined) return + clearTimeout(pending.timer) + this.pending.delete(requestId) + pending.reject(error) + } +} + +function renderError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/experimental/inspector/src/shared/bridge/validation.ts b/packages/experimental/inspector/src/shared/bridge/validation.ts new file mode 100644 index 0000000000..76a776bbfa --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/validation.ts @@ -0,0 +1,3 @@ +/** Bridge-facing exports for the shared untrusted-value validation primitives. */ + +export * from '../validation.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/version.ts b/packages/experimental/inspector/src/shared/bridge/version.ts new file mode 100644 index 0000000000..7fca05a3c6 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/version.ts @@ -0,0 +1,2 @@ +/** Current Inspector wire version. Pre-release peers reject every other version. */ +export const INSPECTOR_PROTOCOL_VERSION = 0 as const diff --git a/packages/experimental/inspector/src/shared/cdp/capabilities.ts b/packages/experimental/inspector/src/shared/cdp/capabilities.ts new file mode 100644 index 0000000000..d0eb408699 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/capabilities.ts @@ -0,0 +1,28 @@ +/** Explicit operation support advertised by each Inspector realm. */ + +/** Runtime operations implemented by a realm backend. */ +export type RuntimeOperation = + | 'evaluate' + | 'get-properties' + | 'call-function' + | 'await-promise' + | 'release-object' + | 'release-object-group' + | 'global-lexical-scope-names' + +/** Console operations implemented by a realm backend. */ +export type ConsoleOperation = 'events' | 'exceptions' | 'clear' + +/** Source catalog operations implemented by a realm backend. */ +export type SourceOperation = 'catalog' | 'content' | 'source-map' + +/** Active debugger operations implemented by a realm backend. */ +export type DebuggerOperation = 'breakpoint' | 'pause' | 'resume' | 'step' | 'call-frame' + +/** Complete capability declaration for one inspected realm. */ +export interface InspectorRealmCapabilities { + readonly runtime: readonly RuntimeOperation[] + readonly console: readonly ConsoleOperation[] + readonly sources: readonly SourceOperation[] + readonly debugger: readonly DebuggerOperation[] +} diff --git a/packages/experimental/inspector/src/shared/cdp/console.ts b/packages/experimental/inspector/src/shared/cdp/console.ts new file mode 100644 index 0000000000..9655ce54a7 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/console.ts @@ -0,0 +1,46 @@ +/** Realm-neutral Console events emitted by Runtime backends. */ + +import type { RuntimeRemoteObject } from './remote-object.ts' +import type { RuntimeExceptionDetails, RuntimeStackTrace } from './errors.ts' + +/** Console API categories exposed by CDP Runtime. */ +export type RuntimeConsoleType = + | 'log' + | 'debug' + | 'info' + | 'error' + | 'warning' + | 'dir' + | 'dirxml' + | 'table' + | 'trace' + | 'clear' + | 'startGroup' + | 'startGroupCollapsed' + | 'endGroup' + | 'assert' + | 'profile' + | 'profileEnd' + | 'count' + | 'timeEnd' + +/** One Console event associated with a single inspected realm. */ +export interface RuntimeConsoleEvent { + readonly type: RuntimeConsoleType + readonly arguments: readonly RuntimeRemoteObject[] + readonly timestamp: number + readonly contextId?: number + readonly stackTrace?: RuntimeStackTrace +} + +/** One uncaught exception observed in an inspected realm. */ +export interface RuntimeExceptionEvent { + readonly timestamp: number + readonly contextId?: number + readonly details: RuntimeExceptionDetails +} + +/** Console-domain event emitted by a realm backend. */ +export type RuntimeConsoleBackendEvent = + | { readonly type: 'console-api'; readonly event: RuntimeConsoleEvent } + | { readonly type: 'exception'; readonly event: RuntimeExceptionEvent } diff --git a/packages/experimental/inspector/src/shared/cdp/debugger.ts b/packages/experimental/inspector/src/shared/cdp/debugger.ts new file mode 100644 index 0000000000..244b1acd1b --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/debugger.ts @@ -0,0 +1,78 @@ +/** Realm-neutral values used by active debugger backends. */ + +import type { InspectorJsonValue } from '../json.ts' +import type { RuntimeScriptKey } from './ids.ts' +import type { RuntimeStackTrace } from './errors.ts' +import type { RuntimeCompletion } from './operations.ts' +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One source location independent of a CDP ScriptId allocation policy. */ +export interface RuntimeDebuggerLocation { + readonly scriptKey: RuntimeScriptKey + readonly lineNumber: number + readonly columnNumber?: number +} + +/** One lexical scope attached to a paused call frame. */ +export interface RuntimeDebuggerScope { + readonly type: string + readonly object: RuntimeRemoteObject + readonly name?: string + readonly startLocation?: RuntimeDebuggerLocation + readonly endLocation?: RuntimeDebuggerLocation +} + +/** One paused JavaScript call frame. */ +export interface RuntimeDebuggerCallFrame { + readonly callFrameId: string + readonly functionName: string + readonly functionLocation?: RuntimeDebuggerLocation + readonly location: RuntimeDebuggerLocation + readonly url: string + readonly scopeChain: readonly RuntimeDebuggerScope[] + readonly thisObject: RuntimeRemoteObject + readonly returnValue?: RuntimeRemoteObject +} + +/** Engine-independent evaluation request for one paused call frame. */ +export interface RuntimeCallFrameEvaluationRequest { + readonly callFrameId: string + readonly expression: string + readonly objectGroup?: string + readonly includeCommandLineAPI?: boolean + readonly silent?: boolean + readonly returnByValue?: boolean + readonly generatePreview?: boolean + readonly throwOnSideEffect?: boolean + readonly timeoutMs?: number +} + +/** Optional native script-cache limit requested while enabling Debugger. */ +export interface RuntimeDebuggerEnableRequest { + readonly maxScriptsCacheSize?: number +} + +/** Optional termination requested while resuming a native debugger. */ +export interface RuntimeDebuggerResumeRequest { + readonly terminateOnResume?: boolean +} + +/** Debugger lifecycle notification emitted by a realm backend. */ +export type RuntimeDebuggerEvent = + | { + readonly type: 'paused' + readonly callFrames: readonly RuntimeDebuggerCallFrame[] + readonly reason: string + readonly data?: InspectorJsonValue + readonly hitBreakpoints?: readonly string[] + readonly asyncStackTrace?: RuntimeStackTrace + } + | { readonly type: 'resumed' } + | { + readonly type: 'breakpoint-resolved' + readonly breakpointId: string + readonly location: RuntimeDebuggerLocation + } + +/** Active debugger operation result containing a Runtime value. */ +export type RuntimeCallFrameEvaluation = RuntimeCompletion diff --git a/packages/experimental/inspector/src/shared/cdp/errors.ts b/packages/experimental/inspector/src/shared/cdp/errors.ts new file mode 100644 index 0000000000..d11f949d57 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/errors.ts @@ -0,0 +1,30 @@ +/** Realm-neutral JavaScript exception and stack information. */ + +import type { RuntimeScriptKey } from './ids.ts' +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One source location in a Runtime exception stack. */ +export interface RuntimeCallFrame { + readonly functionName: string + readonly scriptKey?: RuntimeScriptKey + readonly url: string + readonly lineNumber: number + readonly columnNumber: number +} + +/** JavaScript stack information independent of a Debugger script id. */ +export interface RuntimeStackTrace { + readonly description?: string + readonly callFrames: readonly RuntimeCallFrame[] + readonly parent?: RuntimeStackTrace +} + +/** JavaScript exception produced while executing one Runtime command. */ +export interface RuntimeExceptionDetails { + readonly text: string + readonly lineNumber: number + readonly columnNumber: number + readonly url?: string + readonly stackTrace?: RuntimeStackTrace + readonly exception?: RuntimeRemoteObject +} diff --git a/packages/experimental/inspector/src/shared/cdp/ids.ts b/packages/experimental/inspector/src/shared/cdp/ids.ts new file mode 100644 index 0000000000..7bfd19547f --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/ids.ts @@ -0,0 +1,12 @@ +/** Opaque identifiers owned by normalized realm backends. */ + +import type { InspectorId } from '../identity.ts' + +/** Worker identity of one active Host or Client realm incarnation. */ +export type InspectorRealmId = InspectorId<'InspectorRealmId'> + +/** Backend-owned object handle interpreted only by its realm session. */ +export type RuntimeBackendObjectHandle = InspectorId<'RuntimeBackendObjectHandle'> + +/** Backend-independent identity of one script in a realm catalog. */ +export type RuntimeScriptKey = InspectorId<'RuntimeScriptKey'> diff --git a/packages/experimental/inspector/src/shared/cdp/index.ts b/packages/experimental/inspector/src/shared/cdp/index.ts new file mode 100644 index 0000000000..5a0ea5bcdb --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/index.ts @@ -0,0 +1,11 @@ +/** Realm-neutral Runtime, Console, Source, and Debugger protocol types. */ + +export * from './capabilities.ts' +export * from './console.ts' +export * from './debugger.ts' +export * from './errors.ts' +export * from './ids.ts' +export * from './operations.ts' +export * from './property.ts' +export * from './remote-object.ts' +export * from './sources.ts' diff --git a/packages/experimental/inspector/src/shared/cdp/operations.ts b/packages/experimental/inspector/src/shared/cdp/operations.ts new file mode 100644 index 0000000000..56692a22dc --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/operations.ts @@ -0,0 +1,80 @@ +/** Realm-neutral Runtime operations and results. */ + +import type { InspectorJsonObject, InspectorJsonValue } from '../json.ts' +import type { RuntimeExceptionDetails } from './errors.ts' +import type { + RuntimeInternalPropertyDescriptor, + RuntimePrivatePropertyDescriptor, + RuntimePropertyDescriptor, +} from './property.ts' +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One argument supplied to a function in an inspected realm. */ +export type RuntimeCallArgument = + | { readonly kind: 'value'; readonly value: InspectorJsonValue } + | { readonly kind: 'unserializable'; readonly value: string } + | { readonly kind: 'object'; readonly handle: Handle } + | { readonly kind: 'undefined' } + +/** Engine-independent evaluation options supported by Runtime backends. */ +export interface RuntimeEvaluateRequest { + readonly expression: string + readonly objectGroup?: string + readonly includeCommandLineAPI?: boolean + readonly silent?: boolean + readonly returnByValue?: boolean + readonly generatePreview?: boolean + readonly userGesture?: boolean + readonly awaitPromise?: boolean + readonly disableBreaks?: boolean + readonly replMode?: boolean + readonly allowUnsafeEvalBlockedByCSP?: boolean + readonly throwOnSideEffect?: boolean + readonly serializationOptions?: InspectorJsonObject + readonly timeoutMs?: number +} + +/** Property enumeration request for one backend object. */ +export interface RuntimeGetPropertiesRequest { + readonly handle: Handle + readonly ownProperties?: boolean + readonly accessorPropertiesOnly?: boolean + readonly generatePreview?: boolean + readonly nonIndexedPropertiesOnly?: boolean +} + +/** Function invocation request within one inspected realm. */ +export interface RuntimeCallFunctionRequest { + readonly functionDeclaration: string + readonly receiver?: Handle + readonly arguments?: readonly RuntimeCallArgument[] + readonly objectGroup?: string + readonly silent?: boolean + readonly returnByValue?: boolean + readonly generatePreview?: boolean + readonly userGesture?: boolean + readonly awaitPromise?: boolean + readonly throwOnSideEffect?: boolean + readonly serializationOptions?: InspectorJsonObject +} + +/** Promise-await request for one retained backend object. */ +export interface RuntimeAwaitPromiseRequest { + readonly promise: Handle + readonly returnByValue?: boolean + readonly generatePreview?: boolean +} + +/** Shared result of evaluation, function calls, and promise awaiting. */ +export interface RuntimeCompletion { + readonly result: RuntimeRemoteObject + readonly exceptionDetails?: RuntimeExceptionDetails +} + +/** Shared result of property enumeration. */ +export interface RuntimeProperties { + readonly properties: readonly RuntimePropertyDescriptor[] + readonly internalProperties?: readonly RuntimeInternalPropertyDescriptor[] + readonly privateProperties?: readonly RuntimePrivatePropertyDescriptor[] + readonly exceptionDetails?: RuntimeExceptionDetails +} diff --git a/packages/experimental/inspector/src/shared/cdp/property.ts b/packages/experimental/inspector/src/shared/cdp/property.ts new file mode 100644 index 0000000000..f8af5e04ce --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/property.ts @@ -0,0 +1,31 @@ +/** Realm-neutral property descriptors returned by Runtime backends. */ + +import type { RuntimeRemoteObject } from './remote-object.ts' + +/** One JavaScript property descriptor returned without invoking accessors. */ +export interface RuntimePropertyDescriptor { + readonly name: string + readonly value?: RuntimeRemoteObject + readonly writable?: boolean + readonly get?: RuntimeRemoteObject + readonly set?: RuntimeRemoteObject + readonly configurable: boolean + readonly enumerable: boolean + readonly wasThrown?: boolean + readonly isOwn?: boolean + readonly symbol?: RuntimeRemoteObject +} + +/** One engine-owned property such as `[[Prototype]]`. */ +export interface RuntimeInternalPropertyDescriptor { + readonly name: string + readonly value?: RuntimeRemoteObject +} + +/** One engine private property exposed when a backend supports it. */ +export interface RuntimePrivatePropertyDescriptor { + readonly name: string + readonly value?: RuntimeRemoteObject + readonly get?: RuntimeRemoteObject + readonly set?: RuntimeRemoteObject +} diff --git a/packages/experimental/inspector/src/shared/cdp/realm.ts b/packages/experimental/inspector/src/shared/cdp/realm.ts new file mode 100644 index 0000000000..38b61afb11 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/realm.ts @@ -0,0 +1,157 @@ +/** Environment-independent backend interfaces for inspected JavaScript realms. */ + +import type { RuntimeBackendObjectHandle, RuntimeScriptKey } from './ids.ts' +import type { + RuntimeAwaitPromiseRequest, + RuntimeCallFunctionRequest, + RuntimeCompletion, + RuntimeConsoleBackendEvent, + RuntimeDebuggerEvent, + RuntimeDebuggerEnableRequest, + RuntimeDebuggerResumeRequest, + RuntimeCallFrameEvaluationRequest, + RuntimeEvaluateRequest, + RuntimeGetPropertiesRequest, + RuntimeProperties, + RuntimeScript, +} from './index.ts' + +/** Raw notification emitted by a native engine protocol backend. */ +export interface NativeProtocolNotification { + readonly method: string + readonly params?: Readonly> +} + +/** Explicitly supported or unsupported realm capability. */ +export type RealmCapability = + | { readonly state: 'supported'; readonly backend: Backend } + | { readonly state: 'unsupported'; readonly reason: string } + +/** Runtime operations implemented inside one per-connection realm session. */ +export interface RuntimeBackend { + /** Prepare Runtime events and execution state for this connection. */ + enable(): Promise + /** Disable Runtime events and release backend session state. */ + disable(): Promise + /** + * Evaluate source in this realm. + * @param request - Engine-independent evaluation request. + * @returns Completion containing a value or JavaScript exception. + */ + evaluate(request: RuntimeEvaluateRequest): Promise> + /** + * Enumerate one retained object's properties. + * @param request - Property request containing this backend's object handle. + * @returns Property descriptors and optional exception details. + */ + getProperties( + request: RuntimeGetPropertiesRequest, + ): Promise> + /** + * Invoke a function with references owned by this realm session. + * @param request - Function source, receiver, arguments, and result options. + * @returns Completion containing the invocation result or JavaScript exception. + */ + callFunction( + request: RuntimeCallFunctionRequest, + ): Promise> + /** + * Await one retained Promise. + * @param request - Promise handle and result options. + * @returns Completion containing the fulfilled value or rejection. + */ + awaitPromise( + request: RuntimeAwaitPromiseRequest, + ): Promise> + /** @returns Names visible in the realm's global lexical scope. */ + globalLexicalScopeNames(): Promise + /** + * Release one backend object reference. + * @param handle - Handle owned by this realm session. + */ + releaseObject(handle: RuntimeBackendObjectHandle): Promise + /** + * Release every backend object retained under one group. + * @param group - DevTools object-group name. + */ + releaseObjectGroup(group: string): Promise +} + +/** Realm Console event source. */ +export interface ConsoleBackend { + /** + * Subscribe to Console and uncaught-exception events. + * @param listener - Connection-local event consumer. + * @returns A disposer for the subscription. + */ + subscribe(listener: (event: RuntimeConsoleBackendEvent) => void): () => void + /** Clear backend-owned Console history when supported. */ + clear(): Promise +} + +/** Realm script catalog independent of CDP ScriptId allocation. */ +export interface SourceBackend { + /** @returns Every script currently known to this realm. */ + listScripts(): Promise + /** + * Read source text for one realm-local script key. + * @param scriptKey - Script identity allocated by this realm. + * @returns The complete source text. + */ + getScriptSource(scriptKey: RuntimeScriptKey): Promise + /** + * Read an optional source map for one realm-local script key. + * @param scriptKey - Script identity allocated by this realm. + * @returns Source-map JSON when one exists. + */ + getSourceMap(scriptKey: RuntimeScriptKey): Promise + /** + * Subscribe to scripts discovered after the initial catalog read. + * @param listener - Consumer of newly discovered scripts. + * @returns A disposer for the subscription. + */ + subscribe(listener: (script: RuntimeScript) => void): () => void +} + +/** Active JavaScript debugging backend for one realm session. */ +export interface DebuggerBackend { + /** Enable debugger events for this connection. */ + enable(request: RuntimeDebuggerEnableRequest): Promise>> + /** Disable debugger events for this connection. */ + disable(): Promise>> + /** Pause this realm. */ + pause(): Promise>> + /** Resume this realm. */ + resume(request: RuntimeDebuggerResumeRequest): Promise>> + /** + * Evaluate an expression in one paused frame. + * @param request - Frame identity, expression, and result options. + * @returns A common Runtime completion. + */ + evaluateOnCallFrame( + request: RuntimeCallFrameEvaluationRequest, + ): Promise> + /** + * Subscribe to paused, resumed, and breakpoint events. + * @param listener - Connection-local debugger event consumer. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (event: RuntimeDebuggerEvent) => void): () => void +} + +/** Explicit Host-only native protocol adapter for domains not yet normalized. */ +export interface NativeDomainBackend { + /** + * Execute one native protocol request. + * @param method - CDP method owned by the native engine. + * @param params - Parsed CDP parameters. + * @returns Native response fields. + */ + request(method: string, params: Readonly>): Promise>> + /** + * Subscribe to native protocol notifications. + * @param listener - Notification consumer. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (message: NativeProtocolNotification) => void): () => void +} diff --git a/packages/experimental/inspector/src/shared/cdp/remote-object.ts b/packages/experimental/inspector/src/shared/cdp/remote-object.ts new file mode 100644 index 0000000000..4313f1f0c2 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/remote-object.ts @@ -0,0 +1,78 @@ +/** Realm-neutral JavaScript value descriptions used by Inspector backends. */ + +import type { InspectorObjectReference } from '../cordis/object-reference.ts' +import type { InspectorJsonValue } from '../json.ts' + +/** Runtime value kinds represented by CDP `Runtime.RemoteObject`. */ +export type RuntimeRemoteObjectType = + | 'object' + | 'function' + | 'undefined' + | 'string' + | 'number' + | 'boolean' + | 'symbol' + | 'bigint' + +/** Runtime object subtype hints understood by Chrome DevTools. */ +export type RuntimeRemoteObjectSubtype = + | 'array' + | 'null' + | 'node' + | 'regexp' + | 'date' + | 'map' + | 'set' + | 'weakmap' + | 'weakset' + | 'iterator' + | 'generator' + | 'error' + | 'proxy' + | 'promise' + | 'typedarray' + | 'arraybuffer' + | 'dataview' + | 'webassemblymemory' + | 'wasmvalue' + +/** Shallow property rendered inline by DevTools. */ +export interface RuntimePropertyPreview { + readonly name: string + readonly type: RuntimeRemoteObjectType | 'accessor' + readonly value?: string + readonly valuePreview?: RuntimeObjectPreview + readonly subtype?: RuntimeRemoteObjectSubtype +} + +/** Shallow object rendering that never carries a live-object reference. */ +export interface RuntimeObjectPreview { + readonly type: RuntimeRemoteObjectType + readonly subtype?: RuntimeRemoteObjectSubtype + readonly description?: string + readonly overflow: boolean + readonly properties: readonly RuntimePropertyPreview[] +} + +/** Engine-independent description of one JavaScript value. */ +export interface RuntimeRemoteObjectDescriptor { + readonly type: RuntimeRemoteObjectType + readonly subtype?: RuntimeRemoteObjectSubtype + readonly className?: string + readonly value?: InspectorJsonValue + readonly unserializableValue?: string + readonly description?: string + readonly preview?: RuntimeObjectPreview +} + +/** Backend-owned reference to a retained object in one realm session. */ +export interface RuntimeBackendObjectReference { + readonly handle: Handle +} + +/** Realm-neutral value plus optional backend and Cordis identities. */ +export interface RuntimeRemoteObject { + readonly descriptor: RuntimeRemoteObjectDescriptor + readonly object?: RuntimeBackendObjectReference + readonly semanticReference?: InspectorObjectReference +} diff --git a/packages/experimental/inspector/src/shared/cdp/sources.ts b/packages/experimental/inspector/src/shared/cdp/sources.ts new file mode 100644 index 0000000000..22b601bc76 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cdp/sources.ts @@ -0,0 +1,19 @@ +/** Realm-neutral script metadata used by source backends. */ + +import type { RuntimeScriptKey } from './ids.ts' + +/** One script visible in a realm's source catalog. */ +export interface RuntimeScript { + readonly scriptKey: RuntimeScriptKey + readonly url: string + readonly hash: string + readonly buildId?: string + readonly sourceMapUrl?: string + readonly startLine: number + readonly startColumn: number + readonly endLine: number + readonly endColumn: number + readonly executionContextId?: number + readonly isModule?: boolean + readonly length?: number +} diff --git a/packages/experimental/inspector/src/shared/identity.ts b/packages/experimental/inspector/src/shared/identity.ts new file mode 100644 index 0000000000..3983e03f25 --- /dev/null +++ b/packages/experimental/inspector/src/shared/identity.ts @@ -0,0 +1,19 @@ +/** Shared branded-identifier construction without assigning protocol ownership. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** String branded with one Inspector identity role. */ +export type InspectorId = Branded + +/** + * Validate and brand a non-empty identifier received from or sent across a runtime boundary. + * @param value - Untrusted identifier text. + * @param label - Field name used in validation errors. + * @returns The role-branded identifier. + */ +export function inspectorId(value: string, label: string): InspectorId { + if (value.length === 0 || value.length > 256) { + throw new Error(`inspector protocol: ${label} must contain 1 to 256 characters`) + } + return value as InspectorId +} diff --git a/packages/experimental/inspector/src/shared/index.ts b/packages/experimental/inspector/src/shared/index.ts new file mode 100644 index 0000000000..25d761878f --- /dev/null +++ b/packages/experimental/inspector/src/shared/index.ts @@ -0,0 +1,18 @@ +/** Environment-independent Inspector models and bridge protocol exports. */ + +export * from './bridge/messages/control.ts' +export * from './bridge/control-codec.ts' +export * from './cordis/snapshot.ts' +export * from './bridge/messages/cordis.ts' +export * from './bridge/messages/runtime/index.ts' +export * from './bridge/messages/sources/index.ts' +export * from './bridge/messages/network.ts' +export * from './network/observation.ts' +export * from './bridge/ids.ts' +export * from './json.ts' +export * from './cordis/object-reference.ts' +export * from './bridge/messages/query/index.ts' +export * from './bridge/query-reader.ts' +export * from './bridge/rpc.ts' +export * from './cdp/index.ts' +export * from './bridge/messages/observation.ts' diff --git a/packages/experimental/inspector/src/shared/json.ts b/packages/experimental/inspector/src/shared/json.ts new file mode 100644 index 0000000000..07d048a402 --- /dev/null +++ b/packages/experimental/inspector/src/shared/json.ts @@ -0,0 +1,79 @@ +/** JSON values admitted by every Inspector cross-realm message. */ + +/** JSON scalar accepted by Inspector transports. */ +export type InspectorJsonPrimitive = null | boolean | number | string + +/** Recursively JSON-compatible value accepted by Inspector transports. */ +export type InspectorJsonValue = + | InspectorJsonPrimitive + | readonly InspectorJsonValue[] + | InspectorJsonObject + +/** JSON-compatible object accepted by Inspector transports. */ +export interface InspectorJsonObject { + readonly [key: string]: InspectorJsonValue +} + +/** + * Test that a value can cross both MessagePort and JSON WebSocket carriers without coercion. + * @param value - Candidate wire value. + * @returns Whether the value is lossless JSON data. + */ +export function isJsonValue(value: unknown): value is InspectorJsonValue { + return visitJson(value, new Set()) +} + +/** + * Require a plain JSON object and return it with a narrowed type. + * @param value - Candidate wire value. + * @param label - Field name used in validation errors. + * @returns The validated JSON object. + */ +export function requireJsonObject(value: unknown, label: string): InspectorJsonObject { + if (!isPlainObject(value) || !isJsonValue(value)) { + throw new Error(`inspector protocol: ${label} must be a JSON object`) + } + return value +} + +/** + * Compute the UTF-8 byte length of a JSON wire value. + * @param value - Validated JSON value. + * @returns Its encoded byte length. + */ +export function jsonByteLength(value: InspectorJsonValue): number { + return new TextEncoder().encode(JSON.stringify(value)).byteLength +} + +/** + * Test whether a value is a plain object with string own keys. + * @param value - Candidate object. + * @returns Whether the value has `Object.prototype` or a null prototype. + */ +export function isPlainObject(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype = Reflect.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function visitJson(value: unknown, ancestors: Set): value is InspectorJsonValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object' || ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype || Reflect.ownKeys(value).length !== value.length + 1) return false + return value.every(item => visitJson(item, ancestors)) + } + if (!isPlainObject(value)) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !('value' in descriptor) || !visitJson(descriptor.value, ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/experimental/inspector/src/shared/validation.ts b/packages/experimental/inspector/src/shared/validation.ts new file mode 100644 index 0000000000..d68d6944f0 --- /dev/null +++ b/packages/experimental/inspector/src/shared/validation.ts @@ -0,0 +1,93 @@ +/** Shared exact-object readers for versioned Inspector wire protocols. */ + +import { inspectorId, type InspectorId } from './identity.ts' +import { isPlainObject } from './json.ts' + +/** + * Require a plain object containing only the listed fields. + * @param value - Candidate object. + * @param keys - Complete field allowlist. + * @param label - Object name used in validation errors. + * @returns The validated plain object. + */ +export function exactObject(value: unknown, keys: readonly string[], label: string): Record { + if (!isPlainObject(value)) throw new Error(`inspector protocol: ${label} must be an object`) + exactKeys(value, keys, label) + return value +} + +/** + * Reject fields outside one versioned object's declared field set. + * @param value - Plain object being validated. + * @param keys - Complete field allowlist. + * @param label - Object name used in validation errors. + */ +export function exactKeys(value: Record, keys: readonly string[], label: string): void { + const allowed = new Set(keys) + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string' || !allowed.has(key)) { + throw new Error(`inspector protocol: ${label} has unknown field ${JSON.stringify(String(key))}`) + } + } +} + +/** + * Read one non-empty opaque identifier. + * @param value - Candidate identifier. + * @param label - Field name used in validation errors. + * @returns The role-branded identifier. + */ +export function wireId(value: unknown, label: string): InspectorId { + if (typeof value !== 'string') throw new Error(`inspector protocol: ${label} must be a string`) + return inspectorId(value, label) +} + +/** + * Read one optional string field. + * @param value - Object containing the field. + * @param key - Field name. + * @returns An empty object or the validated field. + */ +export function optionalString( + value: Record, + key: Key, +): { readonly [Property in Key]?: string } { + const item = value[key] + if (item === undefined) return {} + if (typeof item !== 'string') throw new Error(`inspector protocol: ${key} must be a string`) + return { [key]: item } as { readonly [Property in Key]?: string } +} + +/** + * Read one optional boolean field. + * @param value - Object containing the field. + * @param key - Field name. + * @returns An empty object or the validated field. + */ +export function optionalBoolean( + value: Record, + key: Key, +): { readonly [Property in Key]?: boolean } { + const item = value[key] + if (item === undefined) return {} + if (typeof item !== 'boolean') throw new Error(`inspector protocol: ${key} must be a boolean`) + return { [key]: item } as { readonly [Property in Key]?: boolean } +} + +/** + * Read one optional non-negative finite number field. + * @param value - Object containing the field. + * @param key - Field name. + * @returns An empty object or the validated field. + */ +export function optionalNonNegativeNumber( + value: Record, + key: Key, +): { readonly [Property in Key]?: number } { + const item = value[key] + if (item === undefined) return {} + if (typeof item !== 'number' || !Number.isFinite(item) || item < 0) { + throw new Error(`inspector protocol: ${key} must be a non-negative finite number`) + } + return { [key]: item } as { readonly [Property in Key]?: number } +} diff --git a/packages/experimental/inspector/tests/protocol.host.spec.ts b/packages/experimental/inspector/tests/protocol.host.spec.ts new file mode 100644 index 0000000000..1e538194e5 --- /dev/null +++ b/packages/experimental/inspector/tests/protocol.host.spec.ts @@ -0,0 +1,265 @@ +/** Worker and shared protocol behavior. */ + +import { describe, expect, it, vi } from 'vitest' +import { INSPECTOR_PROTOCOL_VERSION, parseSourceFrame, parseWorkerSourceFrame } from '../src/shared/bridge/messages/observation.ts' +import { InspectorSourceRegistry, type InspectorRecordConsumer, type SourceConnection } from '../src/worker/bridge/hub.ts' + +describe('Inspector source protocol', () => { + it('rebuilds a valid source frame and rejects non-JSON payloads', () => { + const frame = parseSourceFrame({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append', + sourceId: 'host-1', + generation: 'generation-1', + firstSequence: 1, + droppedBefore: 0, + records: [{ monotonicMs: 12, topic: 'probe', payload: { ok: true } }], + }, 4) + expect(frame.t).toBe('source/append') + expect(() => parseSourceFrame({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append', + sourceId: 'host-1', + generation: 'generation-1', + firstSequence: 1, + droppedBefore: 0, + records: [{ monotonicMs: 12, topic: 'probe', payload: { bad: undefined } }], + }, 4)).toThrow('lossless JSON object') + }) + + it('isolates generations and reports sequence gaps', () => { + const replace = vi.fn() + const append = vi.fn() + const close = vi.fn() + const consumer: InspectorRecordConsumer = { + topics: new Set(['probe']), + replace, + append, + close, + } + const replies: unknown[] = [] + const send = vi.fn((frame: unknown) => { replies.push(frame) }) + const closeConnection = vi.fn() + const connection: SourceConnection = { + kind: 'host', + send, + close: closeConnection, + } + const registry = new InspectorSourceRegistry([consumer], 16_384, 4) + registry.receive(connection, { + v: 0, + t: 'source/open', + source: { + sourceId: 'host-1', + generation: 'g-1', + kind: 'host', + label: 'Host', + timeOriginMs: 1_000, + capabilities: [], + }, + topics: ['probe'], + }) + registry.receive(connection, { + v: 0, + t: 'source/append', + sourceId: 'host-1', + generation: 'g-1', + firstSequence: 2, + droppedBefore: 1, + records: [{ monotonicMs: 1, topic: 'probe', payload: { value: 1 } }], + }) + + expect(append).toHaveBeenCalledOnce() + expect(registry.describe()[0]).toMatchObject({ expectedSequence: 3, dropped: 1, topics: { probe: 1 } }) + + registry.receive(connection, { + v: 0, + t: 'source/append', + sourceId: 'host-1', + generation: 'g-1', + firstSequence: 5, + droppedBefore: 0, + records: [], + }) + expect(replies.at(-1)).toMatchObject({ t: 'source/resnapshot', expectedSequence: 3 }) + expect(append).toHaveBeenCalledOnce() + }) + + it('closes only a malformed source connection', () => { + const send = vi.fn() + const closeConnection = vi.fn() + const connection: SourceConnection = { + kind: 'client', + send, + close: closeConnection, + } + const registry = new InspectorSourceRegistry([], 1_024, 2) + registry.receive(connection, { v: 99, t: 'source/open' }) + expect(send).toHaveBeenCalledWith(expect.objectContaining({ t: 'source/rejected' })) + expect(closeConnection).toHaveBeenCalledOnce() + }) + + it('decodes Runtime commands and rejects undeclared fields', () => { + const request = parseWorkerSourceFrame({ + v: 0, + t: 'client-runtime/request', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + requestId: 'request-1', + command: { + op: 'call-function', + functionDeclaration: 'function () { return this.value }', + receiver: 'object-1', + arguments: [{ kind: 'unserializable', value: 'NaN' }], + returnByValue: true, + }, + }) + expect(request).toMatchObject({ + t: 'client-runtime/request', + command: { op: 'call-function', receiver: 'object-1', returnByValue: true }, + }) + if (request.t !== 'client-runtime/request') throw new Error('unexpected frame type') + expect(() => parseWorkerSourceFrame({ + ...request, + command: { ...request.command, unversionedExtension: true }, + })).toThrow('unknown field') + }) + + it('rejects invalid RemoteObject representations', () => { + expect(() => parseSourceFrame({ + v: 0, + t: 'client-runtime/response', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + requestId: 'request-1', + outcome: { + ok: true, + result: { + op: 'evaluate', + completion: { + result: { + descriptor: { type: 'number', value: 1 }, + object: { handle: 'object-1' }, + }, + }, + }, + }, + }, 4)).toThrow('invalid number RemoteObject representation') + }) + + it('decodes exact Client Console lifecycle and event frames', () => { + expect(parseWorkerSourceFrame({ + v: 0, + t: 'client-console/enable', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + })).toMatchObject({ t: 'client-console/enable', sessionId: 'session-1' }) + + const frame = parseSourceFrame({ + v: 0, + t: 'client-console/event', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + event: { + type: 'console-api', + event: { + type: 'log', + arguments: [{ + descriptor: { type: 'object', className: 'Object', description: 'Object' }, + object: { handle: 'object-1' }, + }], + timestamp: 12, + }, + }, + }, 4) + expect(frame).toMatchObject({ + t: 'client-console/event', + sessionId: 'session-1', + event: { + type: 'console-api', + event: { type: 'log', arguments: [{ object: { handle: 'object-1' } }] }, + }, + }) + + expect(() => parseWorkerSourceFrame({ + v: 0, + t: 'client-console/disable', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + extra: true, + })).toThrow('unknown field') + }) + + it('decodes bounded Client source commands and responses', () => { + expect(parseWorkerSourceFrame({ + v: 0, + t: 'client-sources/request', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'source-session-1', + requestId: 'source-request-1', + command: { + op: 'get-content-chunk', + scriptKey: 'bundle', + content: 'source', + offset: 0, + maxBytes: 1024, + }, + })).toMatchObject({ + t: 'client-sources/request', + command: { op: 'get-content-chunk', maxBytes: 1024 }, + }) + + expect(parseSourceFrame({ + v: 0, + t: 'client-sources/response', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'source-session-1', + requestId: 'source-request-1', + outcome: { + ok: true, + result: { + op: 'get-content-chunk', + scriptKey: 'bundle', + content: 'source', + available: true, + offset: 0, + nextOffset: 3, + data: 'YWJj', + eof: true, + }, + }, + }, 4)).toMatchObject({ + t: 'client-sources/response', + outcome: { ok: true, result: { data: 'YWJj', eof: true } }, + }) + + expect(() => parseSourceFrame({ + v: 0, + t: 'client-sources/response', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'source-session-1', + requestId: 'source-request-1', + outcome: { + ok: true, + result: { + op: 'get-content-chunk', + scriptKey: 'bundle', + content: 'source', + available: true, + offset: 0, + nextOffset: 3, + data: 'not base64', + eof: true, + }, + }, + }, 4)).toThrow('chunk data') + }) +}) diff --git a/packages/experimental/inspector/tests/source-buffer.host.spec.ts b/packages/experimental/inspector/tests/source-buffer.host.spec.ts new file mode 100644 index 0000000000..41907763f4 --- /dev/null +++ b/packages/experimental/inspector/tests/source-buffer.host.spec.ts @@ -0,0 +1,43 @@ +/** Worker-side source buffer behavior. */ + +import { describe, expect, it } from 'vitest' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import { InspectorSourceBuffer } from '../src/shared/bridge/buffer.ts' + +const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId') +const generation = inspectorId<'InspectorSourceGeneration'>('generation-buffer-test', 'generation') + +function buffer(maxQueuedRecords = 2): InspectorSourceBuffer { + return new InspectorSourceBuffer({ + topics: ['*'], + maxQueuedRecords, + maxQueuedBytes: 32_768, + maxRecordsPerFrame: 8, + maxFrameBytes: 32_768, + }) +} + +describe('Inspector source buffer', () => { + it('absorbs pre-replacement queue loss exactly once', () => { + const records = buffer(1) + records.publish('test/event', { ordinal: 1 }, 1) + records.publish('test/event', { ordinal: 2 }, 2) + + expect(records.replacement(sourceId, generation)).toMatchObject({ + nextSequence: 2, + records: [], + }) + expect(records.takeBatch(sourceId, generation)).toMatchObject({ + firstSequence: 2, + droppedBefore: 0, + records: [{ topic: 'test/event', payload: { ordinal: 2 } }], + }) + }) + + it('validates records before either carrier can enqueue them', () => { + const records = buffer() + + expect(() => { records.publish('', {}, 1) }).toThrow('topic must contain 1 to 128 characters') + expect(() => { records.publish('test/event', {}, Number.NaN) }).toThrow('monotonicMs must be finite') + }) +}) diff --git a/packages/experimental/inspector/tsconfig.client.json b/packages/experimental/inspector/tsconfig.client.json new file mode 100644 index 0000000000..402c7f4c6a --- /dev/null +++ b/packages/experimental/inspector/tsconfig.client.json @@ -0,0 +1,96 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/bridge/controller.ts", + "src/client/bridge/dispatcher.ts", + "src/client/bridge/lifecycle.ts", + "src/client/bridge/publisher.ts", + "src/client/bridge/rpc.ts", + "src/client/bridge/transport.ts", + "src/client/cdp/console.ts", + "src/client/cdp/debugger.ts", + "src/client/cdp/errors.ts", + "src/client/cdp/heap-profiler.ts", + "src/client/cdp/index.ts", + "src/client/cdp/objects.ts", + "src/client/cdp/profiler.ts", + "src/client/cdp/properties.ts", + "src/client/cdp/runtime.ts", + "src/client/cdp/sources.ts", + "src/client/cdp/stack.ts", + "src/client/index.ts", + "src/client/inspection/cordis.ts", + "src/client/inspection/network.ts", + "src/client/inspection/realm.ts", + "src/client/plugin.ts", + "src/shared/bridge/buffer.ts", + "src/shared/bridge/codec.ts", + "src/shared/bridge/control-codec.ts", + "src/shared/bridge/ids.ts", + "src/shared/bridge/messages/control.ts", + "src/shared/bridge/messages/cordis.ts", + "src/shared/bridge/messages/network.ts", + "src/shared/bridge/messages/observation.ts", + "src/shared/bridge/messages/query/codec.ts", + "src/shared/bridge/messages/query/commands.ts", + "src/shared/bridge/messages/query/frames.ts", + "src/shared/bridge/messages/query/index.ts", + "src/shared/bridge/messages/runtime/command-codec.ts", + "src/shared/bridge/messages/runtime/commands.ts", + "src/shared/bridge/messages/runtime/console-frames.ts", + "src/shared/bridge/messages/runtime/frames.ts", + "src/shared/bridge/messages/runtime/index.ts", + "src/shared/bridge/messages/runtime/value-codec.ts", + "src/shared/bridge/messages/sources/codec.ts", + "src/shared/bridge/messages/sources/commands.ts", + "src/shared/bridge/messages/sources/frames.ts", + "src/shared/bridge/messages/sources/index.ts", + "src/shared/bridge/publisher.ts", + "src/shared/bridge/query-reader.ts", + "src/shared/bridge/rpc.ts", + "src/shared/bridge/validation.ts", + "src/shared/bridge/version.ts", + "src/shared/cdp/capabilities.ts", + "src/shared/cdp/console.ts", + "src/shared/cdp/debugger.ts", + "src/shared/cdp/errors.ts", + "src/shared/cdp/ids.ts", + "src/shared/cdp/index.ts", + "src/shared/cdp/operations.ts", + "src/shared/cdp/property.ts", + "src/shared/cdp/realm.ts", + "src/shared/cdp/remote-object.ts", + "src/shared/cdp/sources.ts", + "src/shared/cordis/collector.ts", + "src/shared/cordis/ids.ts", + "src/shared/cordis/model.ts", + "src/shared/cordis/object-reference.ts", + "src/shared/cordis/object-registry.ts", + "src/shared/cordis/observer.ts", + "src/shared/cordis/projector.ts", + "src/shared/cordis/reader.ts", + "src/shared/cordis/snapshot.ts", + "src/shared/identity.ts", + "src/shared/index.ts", + "src/shared/json.ts", + "src/shared/network/observation.ts", + "src/shared/service.ts", + "src/shared/validation.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json new file mode 100644 index 0000000000..84235269cd --- /dev/null +++ b/packages/experimental/inspector/tsconfig.host.json @@ -0,0 +1,155 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/host/bridge/controller.ts", + "src/host/bridge/dispatcher.ts", + "src/host/bridge/lifecycle.ts", + "src/host/bridge/publisher.ts", + "src/host/bridge/rpc.ts", + "src/host/bridge/transport.ts", + "src/host/cdp/console.ts", + "src/host/cdp/debugger.ts", + "src/host/cdp/errors.ts", + "src/host/cdp/heap-profiler.ts", + "src/host/cdp/index.ts", + "src/host/cdp/objects.ts", + "src/host/cdp/profiler.ts", + "src/host/cdp/properties.ts", + "src/host/cdp/runtime.ts", + "src/host/cdp/sources.ts", + "src/host/cdp/stack.ts", + "src/host/index.ts", + "src/host/inspection/cordis.ts", + "src/host/inspection/network.ts", + "src/host/inspection/realm.ts", + "src/host/plugin.ts", + "src/index.ts", + "src/invariant.ts", + "src/shared/bridge/buffer.ts", + "src/shared/bridge/codec.ts", + "src/shared/bridge/control-codec.ts", + "src/shared/bridge/ids.ts", + "src/shared/bridge/messages/control.ts", + "src/shared/bridge/messages/cordis.ts", + "src/shared/bridge/messages/network.ts", + "src/shared/bridge/messages/observation.ts", + "src/shared/bridge/messages/query/codec.ts", + "src/shared/bridge/messages/query/commands.ts", + "src/shared/bridge/messages/query/frames.ts", + "src/shared/bridge/messages/query/index.ts", + "src/shared/bridge/messages/runtime/command-codec.ts", + "src/shared/bridge/messages/runtime/commands.ts", + "src/shared/bridge/messages/runtime/console-frames.ts", + "src/shared/bridge/messages/runtime/frames.ts", + "src/shared/bridge/messages/runtime/index.ts", + "src/shared/bridge/messages/runtime/value-codec.ts", + "src/shared/bridge/messages/sources/codec.ts", + "src/shared/bridge/messages/sources/commands.ts", + "src/shared/bridge/messages/sources/frames.ts", + "src/shared/bridge/messages/sources/index.ts", + "src/shared/bridge/publisher.ts", + "src/shared/bridge/query-reader.ts", + "src/shared/bridge/rpc.ts", + "src/shared/bridge/validation.ts", + "src/shared/bridge/version.ts", + "src/shared/cdp/capabilities.ts", + "src/shared/cdp/console.ts", + "src/shared/cdp/debugger.ts", + "src/shared/cdp/errors.ts", + "src/shared/cdp/ids.ts", + "src/shared/cdp/index.ts", + "src/shared/cdp/operations.ts", + "src/shared/cdp/property.ts", + "src/shared/cdp/realm.ts", + "src/shared/cdp/remote-object.ts", + "src/shared/cdp/sources.ts", + "src/shared/cordis/collector.ts", + "src/shared/cordis/ids.ts", + "src/shared/cordis/model.ts", + "src/shared/cordis/object-reference.ts", + "src/shared/cordis/object-registry.ts", + "src/shared/cordis/observer.ts", + "src/shared/cordis/projector.ts", + "src/shared/cordis/reader.ts", + "src/shared/cordis/snapshot.ts", + "src/shared/identity.ts", + "src/shared/index.ts", + "src/shared/json.ts", + "src/shared/network/observation.ts", + "src/shared/service.ts", + "src/shared/validation.ts", + "src/worker/bridge/endpoint.ts", + "src/worker/bridge/hub.ts", + "src/worker/bridge/runtime-rpc.ts", + "src/worker/bridge/session.ts", + "src/worker/bridge/source-rpc.ts", + "src/worker/cdp/domains/debugger/cdp-params.ts", + "src/worker/cdp/domains/debugger/index.ts", + "src/worker/cdp/domains/debugger/projector.ts", + "src/worker/cdp/domains/debugger/script-registry.ts", + "src/worker/cdp/domains/debugger/session.ts", + "src/worker/cdp/domains/dom/index.ts", + "src/worker/cdp/domains/dom/model.ts", + "src/worker/cdp/domains/dom/session.ts", + "src/worker/cdp/domains/native.ts", + "src/worker/cdp/domains/network/session.ts", + "src/worker/cdp/domains/runtime/cdp-params.ts", + "src/worker/cdp/domains/runtime/index.ts", + "src/worker/cdp/domains/runtime/object-table.ts", + "src/worker/cdp/domains/runtime/session.ts", + "src/worker/cdp/ids.ts", + "src/worker/cdp/protocol.ts", + "src/worker/cdp/realm-sessions.ts", + "src/worker/cdp/session.ts", + "src/worker/cdp/target.ts", + "src/worker/entry.ts", + "src/worker/inspection/cordis-query.ts", + "src/worker/inspection/cordis-store.ts", + "src/worker/inspection/network-store.ts", + "src/worker/inspection/query-router.ts", + "src/worker/inspection/realm-store.ts", + "src/worker/inspection/realm.ts", + "src/worker/realms/client/bridge.ts", + "src/worker/realms/client/console.ts", + "src/worker/realms/client/debugger.ts", + "src/worker/realms/client/index.ts", + "src/worker/realms/client/runtime.ts", + "src/worker/realms/client/scripts.ts", + "src/worker/realms/client/sources.ts", + "src/worker/realms/client/values.ts", + "src/worker/realms/host/bridge.ts", + "src/worker/realms/host/console.ts", + "src/worker/realms/host/debugger.ts", + "src/worker/realms/host/index.ts", + "src/worker/realms/host/runtime.ts", + "src/worker/realms/host/scripts.ts", + "src/worker/realms/host/sources.ts", + "src/worker/realms/host/values.ts", + "src/worker/server.ts" + ], + "references": [ + { + "path": "../../util/brand" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../host/webserver" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/crypto" + } + ] +} diff --git a/packages/experimental/inspector/tsconfig.json b/packages/experimental/inspector/tsconfig.json new file mode 100644 index 0000000000..2eca820546 --- /dev/null +++ b/packages/experimental/inspector/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.host.json" + }, + { + "path": "./tsconfig.client.json" + } + ] +} diff --git a/packages/experimental/inspector/tsdown.config.ts b/packages/experimental/inspector/tsdown.config.ts new file mode 100644 index 0000000000..349255c09b --- /dev/null +++ b/packages/experimental/inspector/tsdown.config.ts @@ -0,0 +1,22 @@ +import type { UserConfig } from 'tsdown' +import { clientBundle } from '../../client/tsdown.client.ts' + +const worker: UserConfig = { + entry: { worker: 'lib/types/worker/entry.js' }, + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + outputOptions: { inlineDynamicImports: true }, + deps: { neverBundle: specifier => specifier === 'ws' }, +} + +/** Build the Host plugin and Worker during the Host pass, and the dynamic Client plugin during the Client pass. */ +export default clientBundle( + '@deepseek-ai/dsh-experimental-inspector', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true, companions: [worker] }, +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb8a2e9cf..8eb529f007 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4794,6 +4794,49 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) + packages/experimental/inspector: + dependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-client-modules': + specifier: workspace:^ + version: link:../../client/modules + '@deepseek-ai/dsh-util-crypto': + specifier: workspace:^ + version: link:../../util/crypto + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + ws: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-host-webserver': + specifier: workspace:^ + version: link:../../host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + playwright: + specifier: ^1.49.0 + version: 1.61.1 + tsx: + specifier: ^4.19.2 + version: 4.22.4 + packages/experimental/tool-agent-team: dependencies: '@deepseek-ai/schemastery': diff --git a/tsconfig.base.json b/tsconfig.base.json index 83c2432053..374f07f58f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -125,6 +125,7 @@ "@deepseek-ai/dsh-experimental-tool-agent-team/invariant": ["./packages/experimental/tool-agent-team/src/invariant.ts"], "@deepseek-ai/dsh-experimental-webworker-runtime/invariant": ["./packages/experimental/webworker-runtime/src/invariant.ts"], "@deepseek-ai/dsh-experimental-webworker-packer/invariant": ["./packages/experimental/webworker-packer/src/invariant.ts"], + "@deepseek-ai/dsh-experimental-inspector/invariant": ["./packages/experimental/inspector/src/invariant.ts"], "@deepseek-ai/dsh-util-crypto/invariant": ["./packages/util/crypto/src/invariant.ts"], "@deepseek-ai/dsh-*/invariant": [ "./packages/core/*/src/invariant.ts", @@ -271,6 +272,8 @@ "@deepseek-ai/dsh-experimental-tool-agent-team": ["./packages/experimental/tool-agent-team/src"], "@deepseek-ai/dsh-experimental-webworker-runtime": ["./packages/experimental/webworker-runtime/src"], "@deepseek-ai/dsh-experimental-webworker-packer": ["./packages/experimental/webworker-packer/src"], + "@deepseek-ai/dsh-experimental-inspector": ["./packages/experimental/inspector/src"], + "@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"], "@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"], "@deepseek-ai/dsh-*": [ "./packages/core/*/src", diff --git a/tsconfig.client.json b/tsconfig.client.json index cdba4c13a6..bd8d98e4b8 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -51,6 +51,7 @@ { "path": "./packages/client/ui-attachment" }, { "path": "./packages/client/ui-primitives" }, { "path": "./packages/client/modules" }, + { "path": "./packages/experimental/inspector/tsconfig.client.json" }, { "path": "./packages/client/hmr" }, { "path": "./packages/client/connection/tsconfig.client.json" }, { "path": "./packages/typert/registry" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index b91be8e6ca..d4cd66e776 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -276,6 +276,7 @@ { "path": "./packages/test-support/loader-smoke" }, { "path": "./packages/test-support/llm-mock-server" }, { "path": "./packages/experimental/webworker-packer" }, + { "path": "./packages/experimental/inspector/tsconfig.host.json" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/tool-subagent-control" }, diff --git a/vitest.config.ts b/vitest.config.ts index 7374c355a6..c0e346c1c4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -251,6 +251,12 @@ export default defineConfig({ // coverage lane exists. 'packages/experimental/webworker-runtime/src/**', 'packages/experimental/webworker-packer/src/*', + // Inspector behavior spans a Node Worker, the Host isolate, and a real + // browser realm. Its focused specs cover pure logic, while its Worker, + // Debugger, Chromium, and Loader suites run the assembled paths that + // the parent Vitest process cannot attribute. TODO(inspector): remove + // when the coverage lane can merge cross-realm V8 coverage. + 'packages/experimental/inspector/src/**', 'packages/client/modules/src/client/system.ts', 'packages/client/hmr/src/client/index.ts', // Web config-tree boot round: the new host-side web-transport halves diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index e28e70e765..530a32d745 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -43,7 +43,10 @@ export default defineConfig({ // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'], - exclude: ['**/*.expected.e2e.ts'], + exclude: [ + '**/*.expected.e2e.ts', + 'packages/experimental/inspector/tests/client-browser.e2e.ts', + ], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate. diff --git a/vitest.web.config.ts b/vitest.web.config.ts index 7c20ab6462..ee8ed8be34 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ include: [ 'apps/web/tests/**/*.e2e.ts', 'apps/web/tests/**/*.snapshot.ts', + 'packages/experimental/inspector/tests/client-browser.e2e.ts', ], // Local and record runs stay serial. CI runs workspace-mutating HMR and // dynamic Cordis lifecycle coverage before parallelizing the remaining files. From 19f0076668b8973c5c7bc43275d0d635a8504b98 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:25:24 +0800 Subject: [PATCH 064/130] feat(inspector): connect Host and Client producers --- .../inspector/src/client/bridge/controller.ts | 13 + .../inspector/src/client/bridge/dispatcher.ts | 66 +++ .../inspector/src/client/bridge/lifecycle.ts | 40 ++ .../inspector/src/client/bridge/publisher.ts | 110 +++++ .../inspector/src/client/bridge/rpc.ts | 62 +++ .../inspector/src/client/bridge/transport.ts | 274 ++++++++++++ .../inspector/src/client/cdp/console.ts | 176 ++++++++ .../inspector/src/client/cdp/debugger.ts | 11 + .../inspector/src/client/cdp/errors.ts | 10 + .../inspector/src/client/cdp/heap-profiler.ts | 11 + .../inspector/src/client/cdp/index.ts | 26 ++ .../inspector/src/client/cdp/objects.ts | 406 +++++++++++++++++ .../inspector/src/client/cdp/profiler.ts | 11 + .../inspector/src/client/cdp/properties.ts | 160 +++++++ .../inspector/src/client/cdp/runtime.ts | 423 ++++++++++++++++++ .../inspector/src/client/cdp/sources.ts | 233 ++++++++++ .../inspector/src/client/cdp/stack.ts | 78 ++++ .../inspector/src/client/index.ts | 3 + .../inspector/src/client/inspection/realm.ts | 37 ++ .../inspector/src/client/plugin.ts | 61 +++ .../inspector/src/host/bridge/controller.ts | 350 +++++++++++++++ .../inspector/src/host/bridge/dispatcher.ts | 48 ++ .../inspector/src/host/bridge/lifecycle.ts | 125 ++++++ .../inspector/src/host/bridge/publisher.ts | 64 +++ .../inspector/src/host/bridge/rpc.ts | 59 +++ .../inspector/src/host/bridge/transport.ts | 106 +++++ .../inspector/src/host/cdp/console.ts | 20 + .../inspector/src/host/cdp/debugger.ts | 11 + .../inspector/src/host/cdp/errors.ts | 10 + .../inspector/src/host/cdp/heap-profiler.ts | 11 + .../inspector/src/host/cdp/index.ts | 26 ++ .../inspector/src/host/cdp/objects.ts | 12 + .../inspector/src/host/cdp/profiler.ts | 11 + .../inspector/src/host/cdp/properties.ts | 11 + .../inspector/src/host/cdp/runtime.ts | 42 ++ .../inspector/src/host/cdp/sources.ts | 20 + .../inspector/src/host/cdp/stack.ts | 4 + .../experimental/inspector/src/host/index.ts | 3 + .../inspector/src/host/inspection/realm.ts | 22 + .../experimental/inspector/src/host/plugin.ts | 81 ++++ packages/experimental/inspector/src/index.ts | 108 +++++ .../tests/client-runtime.client.spec.ts | 247 ++++++++++ .../tests/client-sources.client.spec.ts | 84 ++++ .../tests/client-stack.client.spec.ts | 31 ++ .../tests/loader-composition.host.spec.ts | 82 ++++ .../inspector/tests/plugin.client.spec.ts | 302 +++++++++++++ .../inspector/tests/plugin.host.spec.ts | 136 ++++++ .../tests/port-selection.host.spec.ts | 42 ++ .../tests/worker-lifecycle.host.spec.ts | 35 ++ 49 files changed, 4314 insertions(+) create mode 100644 packages/experimental/inspector/src/client/bridge/controller.ts create mode 100644 packages/experimental/inspector/src/client/bridge/dispatcher.ts create mode 100644 packages/experimental/inspector/src/client/bridge/lifecycle.ts create mode 100644 packages/experimental/inspector/src/client/bridge/publisher.ts create mode 100644 packages/experimental/inspector/src/client/bridge/rpc.ts create mode 100644 packages/experimental/inspector/src/client/bridge/transport.ts create mode 100644 packages/experimental/inspector/src/client/cdp/console.ts create mode 100644 packages/experimental/inspector/src/client/cdp/debugger.ts create mode 100644 packages/experimental/inspector/src/client/cdp/errors.ts create mode 100644 packages/experimental/inspector/src/client/cdp/heap-profiler.ts create mode 100644 packages/experimental/inspector/src/client/cdp/index.ts create mode 100644 packages/experimental/inspector/src/client/cdp/objects.ts create mode 100644 packages/experimental/inspector/src/client/cdp/profiler.ts create mode 100644 packages/experimental/inspector/src/client/cdp/properties.ts create mode 100644 packages/experimental/inspector/src/client/cdp/runtime.ts create mode 100644 packages/experimental/inspector/src/client/cdp/sources.ts create mode 100644 packages/experimental/inspector/src/client/cdp/stack.ts create mode 100644 packages/experimental/inspector/src/client/index.ts create mode 100644 packages/experimental/inspector/src/client/inspection/realm.ts create mode 100644 packages/experimental/inspector/src/client/plugin.ts create mode 100644 packages/experimental/inspector/src/host/bridge/controller.ts create mode 100644 packages/experimental/inspector/src/host/bridge/dispatcher.ts create mode 100644 packages/experimental/inspector/src/host/bridge/lifecycle.ts create mode 100644 packages/experimental/inspector/src/host/bridge/publisher.ts create mode 100644 packages/experimental/inspector/src/host/bridge/rpc.ts create mode 100644 packages/experimental/inspector/src/host/bridge/transport.ts create mode 100644 packages/experimental/inspector/src/host/cdp/console.ts create mode 100644 packages/experimental/inspector/src/host/cdp/debugger.ts create mode 100644 packages/experimental/inspector/src/host/cdp/errors.ts create mode 100644 packages/experimental/inspector/src/host/cdp/heap-profiler.ts create mode 100644 packages/experimental/inspector/src/host/cdp/index.ts create mode 100644 packages/experimental/inspector/src/host/cdp/objects.ts create mode 100644 packages/experimental/inspector/src/host/cdp/profiler.ts create mode 100644 packages/experimental/inspector/src/host/cdp/properties.ts create mode 100644 packages/experimental/inspector/src/host/cdp/runtime.ts create mode 100644 packages/experimental/inspector/src/host/cdp/sources.ts create mode 100644 packages/experimental/inspector/src/host/cdp/stack.ts create mode 100644 packages/experimental/inspector/src/host/index.ts create mode 100644 packages/experimental/inspector/src/host/inspection/realm.ts create mode 100644 packages/experimental/inspector/src/host/plugin.ts create mode 100644 packages/experimental/inspector/src/index.ts create mode 100644 packages/experimental/inspector/tests/client-runtime.client.spec.ts create mode 100644 packages/experimental/inspector/tests/client-sources.client.spec.ts create mode 100644 packages/experimental/inspector/tests/client-stack.client.spec.ts create mode 100644 packages/experimental/inspector/tests/loader-composition.host.spec.ts create mode 100644 packages/experimental/inspector/tests/plugin.client.spec.ts create mode 100644 packages/experimental/inspector/tests/plugin.host.spec.ts create mode 100644 packages/experimental/inspector/tests/port-selection.host.spec.ts create mode 100644 packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts diff --git a/packages/experimental/inspector/src/client/bridge/controller.ts b/packages/experimental/inspector/src/client/bridge/controller.ts new file mode 100644 index 0000000000..67e408cb76 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/controller.ts @@ -0,0 +1,13 @@ +/** Browser Client bridge construction for the Cordis plugin entry. */ + +import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts' +import { ClientInspectorSource } from './transport.ts' + +/** + * Start the browser source transport for one validated Host bootstrap. + * @param bootstrap - Host-injected endpoint and resource limits. + * @returns The active reconnecting Client source. + */ +export function startInspectorClient(bootstrap: InspectorClientBootstrap): ClientInspectorSource { + return new ClientInspectorSource(bootstrap) +} diff --git a/packages/experimental/inspector/src/client/bridge/dispatcher.ts b/packages/experimental/inspector/src/client/bridge/dispatcher.ts new file mode 100644 index 0000000000..535a8ba237 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/dispatcher.ts @@ -0,0 +1,66 @@ +/** Dispatch of validated Worker frames to browser-realm capability handlers. */ + +import type { + ClientConsoleDisableFrame, + ClientConsoleEnableFrame, + ClientRuntimeRequestFrame, + ClientRuntimeSessionClosedFrame, +} from '../../shared/bridge/messages/runtime/index.ts' +import type { ClientSourceRequestFrame, ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts' +import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' + +/** Operations invoked for each Worker-to-Client frame family. */ +export interface ClientBridgeFrameHandlers { + accepted(frame: SourceAcceptedFrame): void + resnapshot(frame: SourceResnapshotFrame): void + rejected(frame: SourceRejectedFrame): void + runtime(frame: ClientRuntimeRequestFrame): void + runtimeClosed(frame: ClientRuntimeSessionClosedFrame): void + consoleEnabled(frame: ClientConsoleEnableFrame): void + consoleDisabled(frame: ClientConsoleDisableFrame): void + sources(frame: ClientSourceRequestFrame): void + sourcesClosed(frame: ClientSourceSessionClosedFrame): void +} + +/** + * Dispatch one validated Worker frame without exposing transport details to domain adapters. + * @param frame - Decoded Worker-to-source frame. + * @param handlers - Browser-realm operations for each frame family. + */ +export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: ClientBridgeFrameHandlers): void { + switch (frame.t) { + case 'source/accepted': + handlers.accepted(frame) + return + case 'source/resnapshot': + handlers.resnapshot(frame) + return + case 'source/rejected': + handlers.rejected(frame) + return + case 'client-runtime/request': + handlers.runtime(frame) + return + case 'client-runtime/session-closed': + handlers.runtimeClosed(frame) + return + case 'client-console/enable': + handlers.consoleEnabled(frame) + return + case 'client-console/disable': + handlers.consoleDisabled(frame) + return + case 'client-sources/request': + handlers.sources(frame) + return + case 'client-sources/session-closed': + handlers.sourcesClosed(frame) + return + default: + return assertNever(frame) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Worker source frame: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/client/bridge/lifecycle.ts b/packages/experimental/inspector/src/client/bridge/lifecycle.ts new file mode 100644 index 0000000000..62e0bc63f9 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/lifecycle.ts @@ -0,0 +1,40 @@ +/** Reconnection lifecycle for the browser Client bridge. */ + +/** Owns one bounded-backoff timer and prevents reconnection after disposal. */ +export class ClientBridgeLifecycle { + private reconnectAttempt = 0 + private reconnectTimer: ReturnType | undefined + private closed = false + + constructor( + private readonly baseDelayMs: number, + private readonly maxDelayMs: number, + ) {} + + /** Reset backoff after the Worker accepts a source generation. */ + connected(): void { + this.reconnectAttempt = 0 + } + + /** + * Schedule the next reconnect attempt unless one is already pending. + * @param connect - Operation that opens the next transport generation. + */ + reconnect(connect: () => void): void { + if (this.reconnectTimer !== undefined || this.closed) return + const cap = Math.min(this.maxDelayMs, this.baseDelayMs * 2 ** this.reconnectAttempt) + this.reconnectAttempt++ + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = undefined + connect() + }, cap / 2 + Math.random() * cap / 2) + } + + /** Stop pending and future reconnect attempts. */ + close(): void { + if (this.closed) return + this.closed = true + if (this.reconnectTimer !== undefined) clearTimeout(this.reconnectTimer) + this.reconnectTimer = undefined + } +} diff --git a/packages/experimental/inspector/src/client/bridge/publisher.ts b/packages/experimental/inspector/src/client/bridge/publisher.ts new file mode 100644 index 0000000000..61a817a5fe --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/publisher.ts @@ -0,0 +1,110 @@ +/** Buffered Client observation publication across reconnecting WebSockets. */ + +import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../../shared/bridge/buffer.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' + +interface ActivePublication { + readonly socket: WebSocket + readonly source: InspectorSourceDescriptor + accepted: boolean +} + +/** Non-blocking Client publisher whose bounded state survives transport reconnects. */ +export class ClientBridgePublisher implements InspectorStatePublisher { + private readonly records: InspectorSourceBuffer + private active: ActivePublication | undefined + private flushTimer: ReturnType | undefined + private closed = false + + constructor( + options: InspectorSourceBufferOptions, + private readonly maxBufferedBytes: number, + ) { + this.records = new InspectorSourceBuffer(options) + } + + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.records.publish(topic, payload, monotonicMs) + this.flush() + } + + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Client source is closed') + this.records.setState(topic, payload, monotonicMs) + this.flush() + } + + /** + * Install one unopened transport generation. + * @param socket - WebSocket carrying the generation. + * @param source - Source identity and generation sent by the socket. + */ + connect(socket: WebSocket, source: InspectorSourceDescriptor): void { + this.active = { socket, source, accepted: false } + } + + /** + * Send retained state and queued observations after Worker acceptance. + * @param socket - Accepted active WebSocket. + */ + accept(socket: WebSocket): void { + const active = this.active + if (active?.socket !== socket) return + active.accepted = true + this.replace(socket) + this.flush() + } + + /** + * Resend retained state for the active generation. + * @param socket - WebSocket that received the resnapshot request. + */ + replace(socket: WebSocket): void { + const active = this.active + if (active?.socket !== socket || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify(this.records.replacement(active.source.sourceId, active.source.generation))) + } + + /** + * Forget one closed transport while retaining buffered state for reconnect. + * @param socket - WebSocket whose close event fired. + */ + disconnect(socket: WebSocket): void { + if (this.active?.socket === socket) this.active = undefined + } + + /** Stop delayed writes and reject later publication. */ + close(): void { + if (this.closed) return + this.closed = true + this.active = undefined + if (this.flushTimer !== undefined) clearTimeout(this.flushTimer) + this.flushTimer = undefined + } + + private flush(): void { + const active = this.active + if (!active?.accepted || active.socket.readyState !== WebSocket.OPEN) return + if (active.socket.bufferedAmount > this.maxBufferedBytes) { + this.scheduleFlush() + return + } + while (this.records.hasPending && active.socket.bufferedAmount <= this.maxBufferedBytes) { + const frame = this.records.takeBatch(active.source.sourceId, active.source.generation) + if (frame === undefined) break + active.socket.send(JSON.stringify(frame)) + } + if (this.records.hasPending) this.scheduleFlush() + } + + private scheduleFlush(): void { + if (this.flushTimer !== undefined || this.closed) return + this.flushTimer = setTimeout(() => { + this.flushTimer = undefined + this.flush() + }, 25) + } +} diff --git a/packages/experimental/inspector/src/client/bridge/rpc.ts b/packages/experimental/inspector/src/client/bridge/rpc.ts new file mode 100644 index 0000000000..19b0c8d7dc --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/rpc.ts @@ -0,0 +1,62 @@ +/** Client-side non-CDP query bridge over the active Worker WebSocket. */ + +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts' + +/** Owns query correlation across reconnecting Client source generations. */ +export class ClientBridgeRpc { + private readonly connection: InspectorQueryConnection + + constructor(options: InspectorQueryConnectionOptions) { + this.connection = new InspectorQueryConnection(options) + } + + /** + * Connect query writes to one accepted Client WebSocket generation. + * @param source - Accepted source descriptor. + * @param socket - Active source WebSocket. + */ + connect(source: InspectorSourceDescriptor, socket: WebSocket): void { + this.connection.connect(source.sourceId, source.generation, { + send: (frame) => { + if (socket.readyState !== WebSocket.OPEN) throw new Error('Inspector Client query socket is not connected') + socket.send(JSON.stringify(frame)) + }, + }) + } + + /** + * Consume a potential query response. + * @param value - Decoded Worker message. + * @returns Whether the message belonged to this RPC protocol. + */ + receive(value: unknown): boolean { + return this.connection.receive(value) + } + + /** + * Execute one non-CDP query through the active Client generation. + * @param query - Typed query operation. + * @returns Its correlated typed result. + */ + request(query: Query): Promise> { + return this.connection.request(query) + } + + /** + * Reject pending requests while permitting a later Client generation. + * @param reason - Failure reported to pending callers. + */ + disconnect(reason: string): void { + this.connection.disconnect(reason) + } + + /** + * Permanently reject all current and future requests. + * @param reason - Failure reported to pending callers. + */ + close(reason: string): void { + this.connection.close(reason) + } +} diff --git a/packages/experimental/inspector/src/client/bridge/transport.ts b/packages/experimental/inspector/src/client/bridge/transport.ts new file mode 100644 index 0000000000..33ad831535 --- /dev/null +++ b/packages/experimental/inspector/src/client/bridge/transport.ts @@ -0,0 +1,274 @@ +/** Client observation and Runtime endpoint over the Inspector Worker's ingest WebSocket. */ + +import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts' +import type { InspectorSourceGeneration } from '../../shared/bridge/ids.ts' +import { isJsonValue, jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { + INSPECTOR_PROTOCOL_VERSION, + parseWorkerSourceFrame, + type SourceCloseFrame, + type SourceOpenFrame, +} from '../../shared/bridge/messages/observation.ts' +import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { ClientConsoleObserver } from '../cdp/console.ts' +import { ClientRuntimeExecutor } from '../cdp/runtime.ts' +import { + ClientSourceCatalog, + ClientSourceCatalogError, + discoverInspectorClientSourceCatalog, +} from '../cdp/sources.ts' +import type { ClientSourceRequestFrame, ClientSourceResponseFrame } from '../../shared/bridge/messages/sources/index.ts' +import { ClientRealmSource } from '../inspection/realm.ts' +import { NETWORK_TOPICS } from '../inspection/network.ts' +import { ClientBridgeLifecycle } from './lifecycle.ts' +import { ClientBridgePublisher } from './publisher.ts' +import { ClientBridgeRpc } from './rpc.ts' +import { dispatchBridgeFrame } from './dispatcher.ts' + +/** Reconnecting Client source whose bounded queue never blocks page work. */ +export class ClientInspectorSource implements InspectorConnection { + private readonly realmSource: ClientRealmSource + private readonly publisher: ClientBridgePublisher + private socket: WebSocket | undefined + private generation: InspectorSourceGeneration | undefined + private accepted = false + private closed = false + private readonly runtime: ClientRuntimeExecutor + private readonly console: ClientConsoleObserver + private readonly queries: ClientBridgeRpc + private readonly lifecycle: ClientBridgeLifecycle + + constructor( + private readonly bootstrap: InspectorClientBootstrap, + label = document.title || 'Client', + private readonly sourceCatalog: ClientSourceCatalog | undefined = discoverInspectorClientSourceCatalog(), + ) { + this.realmSource = new ClientRealmSource(label) + this.lifecycle = new ClientBridgeLifecycle(bootstrap.reconnectBaseMs, bootstrap.reconnectMaxMs) + this.publisher = new ClientBridgePublisher({ + topics: ['*'], + maxQueuedRecords: bootstrap.maxQueuedRecords, + maxQueuedBytes: bootstrap.maxQueuedBytes, + maxRecordsPerFrame: bootstrap.maxRecordsPerFrame, + maxFrameBytes: bootstrap.maxFrameBytes, + }, bootstrap.maxQueuedBytes) + this.runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: bootstrap.maxRuntimeObjectsPerSession, + maxPropertiesPerResult: bootstrap.maxRuntimePropertiesPerResult, + maxResponseBytes: bootstrap.maxFrameBytes, + }, url => this.sourceCatalog?.scriptKeyForUrl(url)) + this.console = new ClientConsoleObserver(this.runtime, (sessionId, event) => { + const socket = this.socket + const generation = this.generation + if (this.closed + || !this.accepted + || socket?.readyState !== WebSocket.OPEN + || generation === undefined) return + const frame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-console/event', + sourceId: this.realmSource.sourceId, + generation, + sessionId, + event, + } as const + if (!isJsonValue(frame) || jsonByteLength(frame) > this.bootstrap.maxFrameBytes) return + try { + socket.send(JSON.stringify(frame)) + } catch { + // The socket close path resets this generation's Runtime and Console state. + } + }, url => this.sourceCatalog?.scriptKeyForUrl(url)) + this.queries = new ClientBridgeRpc({ + timeoutMs: bootstrap.queryTimeoutMs, + maxFrameBytes: bootstrap.maxFrameBytes, + }) + this.connect() + } + + /** Publish one JSON observation without waiting on the ingest socket. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.publisher.publish(topic, payload, monotonicMs) + } + + /** Retain and publish one state value for reconnect and resnapshot recovery. */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Client source is closed') + this.publisher.setState(topic, payload, monotonicMs) + } + + /** Execute one non-CDP query through the accepted Client source generation. */ + request(query: Query): Promise> { + return this.queries.request(query) + } + + /** Permanently stop reconnecting and close the active source generation. */ + close(): void { + if (this.closed) return + this.closed = true + this.console.close() + this.runtime.reset() + this.queries.close('Inspector Client source closed') + this.lifecycle.close() + this.publisher.close() + const socket = this.socket + const generation = this.generation + if (socket?.readyState === WebSocket.OPEN && generation !== undefined) { + const frame: SourceCloseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/close', + sourceId: this.realmSource.sourceId, + generation, + } + socket.send(JSON.stringify(frame)) + socket.close(1000, 'Client source closed') + } else { + socket?.close() + } + this.socket = undefined + } + + private connect(): void { + if (this.closed) return + this.console.reset() + this.runtime.reset() + this.queries.disconnect('Inspector Client source reconnecting') + const source = this.realmSource.connect(this.sourceCatalog !== undefined) + const generation = source.generation + const socket = new WebSocket(this.bootstrap.endpoint, this.bootstrap.protocol) + this.socket = socket + this.generation = generation + this.accepted = false + this.publisher.connect(socket, source) + socket.addEventListener('open', () => { + if (this.socket !== socket || this.closed) return + const frame: SourceOpenFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/open', + source, + topics: ['*', ...NETWORK_TOPICS], + } + socket.send(JSON.stringify(frame)) + }) + socket.addEventListener('message', (event) => { + if (this.socket !== socket || typeof event.data !== 'string') return + try { + if (new TextEncoder().encode(event.data).byteLength > this.bootstrap.maxFrameBytes) { + throw new Error(`inspector protocol: Worker frame exceeds ${String(this.bootstrap.maxFrameBytes)} bytes`) + } + const value = JSON.parse(event.data) as unknown + if (this.queries.receive(value)) return + const frame = parseWorkerSourceFrame(value) + if (frame.t !== 'source/rejected' + && (frame.sourceId !== this.realmSource.sourceId || frame.generation !== generation)) return + dispatchBridgeFrame(frame, { + accepted: () => { + this.accepted = true + this.lifecycle.connected() + this.queries.connect(source, socket) + this.publisher.accept(socket) + }, + resnapshot: () => { this.publisher.replace(socket) }, + rejected: (rejected) => { + console.error(`[inspector] Client source rejected: ${rejected.message}`) + socket.close(1008, 'source rejected') + }, + runtime: (request) => { + void this.executeRuntime(socket, generation, request).catch((error: unknown) => { + console.error('[inspector] Client Runtime transport failed:', error) + socket.close(1011, 'Client Runtime transport failed') + }) + }, + runtimeClosed: (closed) => { + this.console.disable(closed.sessionId) + this.runtime.closeSession(closed.sessionId) + }, + consoleEnabled: (enabled) => { this.console.enable(enabled.sessionId) }, + consoleDisabled: (disabled) => { this.console.disable(disabled.sessionId) }, + sources: (request) => { + void this.executeSourceRequest(socket, generation, request).catch((error: unknown) => { + console.error('[inspector] Client Sources transport failed:', error) + socket.close(1011, 'Client Sources transport failed') + }) + }, + sourcesClosed: () => {}, + }) + } catch (error) { + console.error('[inspector] invalid Worker control frame:', error) + socket.close(1008, 'invalid Worker control frame') + } + }) + socket.addEventListener('close', () => { + if (this.socket !== socket || this.closed) return + this.socket = undefined + this.accepted = false + this.publisher.disconnect(socket) + this.console.reset() + this.runtime.reset() + this.queries.disconnect('Inspector Client source disconnected') + this.lifecycle.reconnect(() => { this.connect() }) + }) + socket.addEventListener('error', () => { + // `close` owns reconnection and keeps one timer. + }) + } + + private async executeRuntime( + socket: WebSocket, + generation: InspectorSourceGeneration, + frame: Extract, { t: 'client-runtime/request' }>, + ): Promise { + const response = await this.runtime.execute(frame) + if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify(response)) + } + + private async executeSourceRequest( + socket: WebSocket, + generation: InspectorSourceGeneration, + frame: ClientSourceRequestFrame, + ): Promise { + let outcome: ClientSourceResponseFrame['outcome'] + try { + if (this.sourceCatalog === undefined) { + throw new ClientSourceCatalogError('invalid-request', 'Client source catalog is unavailable') + } + outcome = { ok: true, result: await this.sourceCatalog.execute(frame.command, this.bootstrap.maxClientSourceBytes) } + } catch (error) { + outcome = { + ok: false, + error: { + code: error instanceof ClientSourceCatalogError ? error.code : 'internal-error', + message: renderError(error).slice(0, 2_048), + }, + } + } + let response: ClientSourceResponseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/response', + sourceId: this.realmSource.sourceId, + generation, + sessionId: frame.sessionId, + requestId: frame.requestId, + outcome, + } + if (!isJsonValue(response) || jsonByteLength(response) > this.bootstrap.maxFrameBytes) { + response = { + ...response, + outcome: { + ok: false, + error: { code: 'result-too-large', message: 'Client source result exceeds the source-frame byte limit' }, + }, + } + } + if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return + socket.send(JSON.stringify(response)) + } + +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/experimental/inspector/src/client/cdp/console.ts b/packages/experimental/inspector/src/client/cdp/console.ts new file mode 100644 index 0000000000..0bccf3d51c --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/console.ts @@ -0,0 +1,176 @@ +/** Client Console observation shared by every active DevTools Runtime session. */ + +import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts' +import type { ClientConsoleCapability } from '../../shared/bridge/messages/runtime/index.ts' +import type { RuntimeConsoleBackendEvent, RuntimeConsoleType } from '../../shared/cdp/index.ts' +import type { ClientRuntimeExecutor } from './runtime.ts' +import { captureClientConsoleStack, clientErrorStack, type ClientScriptKeyResolver } from './stack.ts' + +/** + * Describe browser-side Console observation. + * @returns The Console capability advertised by a browser Client source. + */ +export function consoleBridgeCapability(): ClientConsoleCapability { + return { type: 'client-console' } +} + +/** Receives one Console event whose object handles belong to the given session. */ +export type ClientConsoleSink = ( + sessionId: ClientRuntimeSessionId, + event: RuntimeConsoleBackendEvent, +) => void + +const METHODS = [ + ['log', 'log'], + ['debug', 'debug'], + ['info', 'info'], + ['error', 'error'], + ['warn', 'warning'], + ['dir', 'dir'], + ['dirxml', 'dirxml'], + ['table', 'table'], + ['trace', 'trace'], + ['clear', 'clear'], + ['group', 'startGroup'], + ['groupCollapsed', 'startGroupCollapsed'], + ['groupEnd', 'endGroup'], + ['assert', 'assert'], + ['profile', 'profile'], + ['profileEnd', 'profileEnd'], + ['count', 'count'], + ['timeEnd', 'timeEnd'], +] as const satisfies readonly (readonly [string, RuntimeConsoleType])[] + +type ConsoleMethodName = typeof METHODS[number][0] + +interface InstalledMethod { + readonly name: ConsoleMethodName + readonly original: (...args: unknown[]) => unknown + readonly replacement: (...args: unknown[]) => unknown +} + +/** Installs one transparent console/error observer and fans out session-local values. */ +export class ClientConsoleObserver { + private readonly sessions = new Set() + private readonly installed: InstalledMethod[] = [] + private active = false + private closed = false + + constructor( + private readonly runtime: ClientRuntimeExecutor, + private readonly sink: ClientConsoleSink, + private readonly resolveScript: ClientScriptKeyResolver = () => undefined, + ) {} + + /** + * Start producing events for one DevTools Runtime session. + * @param sessionId - Session whose object table retains event arguments. + */ + enable(sessionId: ClientRuntimeSessionId): void { + if (this.closed) return + this.sessions.add(sessionId) + if (!this.active) this.install() + } + + /** + * Stop producing events and release Console objects for one session. + * @param sessionId - Session being disabled or closed. + */ + disable(sessionId: ClientRuntimeSessionId): void { + this.sessions.delete(sessionId) + this.runtime.releaseObjectGroup(sessionId, 'console') + if (this.sessions.size === 0) this.uninstall() + } + + /** Restore original browser hooks and clear every active session. */ + close(): void { + if (this.closed) return + this.closed = true + this.reset() + } + + /** Stop observing the current source generation while allowing a later reconnect. */ + reset(): void { + this.sessions.clear() + this.uninstall() + } + + private install(): void { + this.active = true + for (const [name, type] of METHODS) { + const candidate: unknown = Reflect.get(console, name) + if (typeof candidate !== 'function') continue + const original = candidate as (...args: unknown[]) => unknown + const capture = (values: readonly unknown[]): void => { this.captureConsole(type, values) } + const replacement = function (this: unknown, ...args: unknown[]): unknown { + const result = Reflect.apply(original, this, args) + const values = name === 'assert' ? args.slice(1) : args + if (name !== 'assert' || !args[0]) capture(values) + return result + } + if (Reflect.set(console, name, replacement)) this.installed.push({ name, original, replacement }) + } + addGlobalListener('error', this.onError) + addGlobalListener('unhandledrejection', this.onUnhandledRejection) + } + + private uninstall(): void { + if (!this.active) return + this.active = false + removeGlobalListener('error', this.onError) + removeGlobalListener('unhandledrejection', this.onUnhandledRejection) + for (const method of this.installed.splice(0).reverse()) { + if (Reflect.get(console, method.name) === method.replacement) Reflect.set(console, method.name, method.original) + } + } + + private readonly onError = (event: Event): void => { + const error = Reflect.get(event, 'error') as unknown + const message = Reflect.get(event, 'message') as unknown + this.captureException(error ?? new Error(typeof message === 'string' ? message : 'Client error')) + } + + private readonly onUnhandledRejection = (event: Event): void => { + this.captureException(Reflect.get(event, 'reason') as unknown) + } + + private captureConsole(type: RuntimeConsoleType, values: readonly unknown[]): void { + const timestamp = Date.now() + const stackTrace = captureClientConsoleStack(this.resolveScript) + queueMicrotask(() => { + for (const sessionId of [...this.sessions]) { + try { + const event = this.runtime.consoleEvent(sessionId, type, values, timestamp, stackTrace) + if (event !== undefined) this.sink(sessionId, event) + } catch { + // Console observation must not affect the page's original console call. + } + } + }) + } + + private captureException(error: unknown): void { + const timestamp = Date.now() + const stackTrace = clientErrorStack(error, this.resolveScript) + queueMicrotask(() => { + for (const sessionId of [...this.sessions]) { + try { + const event = this.runtime.exceptionEvent(sessionId, error, timestamp, stackTrace) + if (event !== undefined) this.sink(sessionId, event) + } catch { + // Exception observation must not affect browser error dispatch. + } + } + }) + } +} + +function addGlobalListener(type: string, listener: EventListener): void { + const add = Reflect.get(globalThis, 'addEventListener') as unknown + if (typeof add === 'function') Reflect.apply(add, globalThis, [type, listener]) +} + +function removeGlobalListener(type: string, listener: EventListener): void { + const remove = Reflect.get(globalThis, 'removeEventListener') as unknown + if (typeof remove === 'function') Reflect.apply(remove, globalThis, [type, listener]) +} diff --git a/packages/experimental/inspector/src/client/cdp/debugger.ts b/packages/experimental/inspector/src/client/cdp/debugger.ts new file mode 100644 index 0000000000..db720a0a33 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/debugger.ts @@ -0,0 +1,11 @@ +/** Client active debugging is not exposed by the source bridge. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe unavailable browser-side active debugging. + * @returns No source capability until a pause-safe Client debugger agent exists. + */ +export function debuggerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/client/cdp/errors.ts b/packages/experimental/inspector/src/client/cdp/errors.ts new file mode 100644 index 0000000000..62bab531df --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/errors.ts @@ -0,0 +1,10 @@ +/** Client Runtime failures that belong to the transport rather than evaluated JavaScript. */ + +import type { ClientRuntimeError } from '../../shared/bridge/messages/runtime/index.ts' + +/** Failure returned through the typed Client Runtime error outcome. */ +export class ClientRuntimeExecutionError extends Error { + constructor(readonly code: ClientRuntimeError['code'], message: string) { + super(message) + } +} diff --git a/packages/experimental/inspector/src/client/cdp/heap-profiler.ts b/packages/experimental/inspector/src/client/cdp/heap-profiler.ts new file mode 100644 index 0000000000..3c875822bd --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/heap-profiler.ts @@ -0,0 +1,11 @@ +/** Client heap profiling is not exposed by the source bridge. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe unavailable browser-side heap profiling. + * @returns No source capability for Client heap profiling. + */ +export function heapProfilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/client/cdp/index.ts b/packages/experimental/inspector/src/client/cdp/index.ts new file mode 100644 index 0000000000..a18a8612c5 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/index.ts @@ -0,0 +1,26 @@ +/** Source-side CDP capability declarations for the browser Client realm. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' +import { consoleBridgeCapability } from './console.ts' +import { debuggerBridgeCapability } from './debugger.ts' +import { heapProfilerBridgeCapability } from './heap-profiler.ts' +import { profilerBridgeCapability } from './profiler.ts' +import { runtimeBridgeCapability } from './runtime.ts' +import { sourcesBridgeCapability } from './sources.ts' + +/** + * Describe Client operations that require Worker-to-page bridge messages. + * @param origin - Origin assigned to the synthetic execution context. + * @param hasSources - Whether the Client bundle source was discovered. + * @returns Capabilities included in the Client source handshake. + */ +export function bridgeCapabilities(origin: string, hasSources: boolean): readonly InspectorSourceCapability[] { + return [ + runtimeBridgeCapability(origin), + consoleBridgeCapability(), + sourcesBridgeCapability(hasSources), + debuggerBridgeCapability(), + profilerBridgeCapability(), + heapProfilerBridgeCapability(), + ].filter((capability): capability is InspectorSourceCapability => capability !== undefined) +} diff --git a/packages/experimental/inspector/src/client/cdp/objects.ts b/packages/experimental/inspector/src/client/cdp/objects.ts new file mode 100644 index 0000000000..25766f79a4 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/objects.ts @@ -0,0 +1,406 @@ +/** Client-local object handles and CDP-compatible RemoteObject serialization. */ + +import { + inspectorId, + type ClientRemoteObjectHandle, +} from '../../shared/bridge/ids.ts' +import { isJsonValue, type InspectorJsonValue } from '../../shared/json.ts' +import type { ClientRuntimeRemoteObject } from '../../shared/bridge/messages/runtime/index.ts' +import type { + RuntimeObjectPreview, + RuntimePropertyPreview, + RuntimeRemoteObjectSubtype, + RuntimeRemoteObjectType, +} from '../../shared/cdp/index.ts' +import { ClientRuntimeExecutionError } from './errors.ts' +import { identifyRealmObject } from '../../shared/cordis/object-registry.ts' + +const MAX_CLASS_PROTOTYPE_DEPTH = 32 + +interface StoredObject { + readonly value: unknown + readonly group: string | undefined +} + +/** Opaque set of handles allocated by one Client Runtime operation. */ +export type ClientObjectAllocation = symbol + +/** Serialization choices inherited by child RemoteObjects. */ +export interface ClientRuntimeObjectOptions { + readonly group?: string + readonly generatePreview?: boolean + readonly returnByValue?: boolean +} + +/** Per-DevTools-session owner of all live Client object references. */ +export class ClientObjectStore { + private readonly objects = new Map() + private readonly groups = new Map>() + private readonly allocations = new Map>() + private nextOrdinal = 1 + + constructor(private readonly maxObjects: number) {} + + /** + * Start tracking handles allocated by one independently settling operation. + * @returns An opaque allocation identity. + */ + beginAllocation(): ClientObjectAllocation { + const allocation = Symbol('Client Runtime object allocation') + this.allocations.set(allocation, new Set()) + return allocation + } + + /** + * Keep an operation's handles and release its allocation bookkeeping. + * @param allocation - Allocation returned by {@link beginAllocation}. + */ + commitAllocation(allocation: ClientObjectAllocation): void { + this.allocations.delete(allocation) + } + + /** + * Resolve one handle or fail without exposing another session's objects. + * @param handle - Client-local object handle. + * @returns The retained JavaScript value. + */ + get(handle: ClientRemoteObjectHandle): unknown { + const object = this.objects.get(handle) + if (object === undefined) throw new ClientRuntimeExecutionError('object-not-found', 'Client RemoteObject was released') + return object.value + } + + /** + * Read the object group inherited by values reached through one handle. + * @param handle - Client-local object handle. + * @returns Its object group, or `undefined` when it is ungrouped. + */ + group(handle: ClientRemoteObjectHandle): string | undefined { + const object = this.objects.get(handle) + if (object === undefined) throw new ClientRuntimeExecutionError('object-not-found', 'Client RemoteObject was released') + return object.group + } + + /** + * Convert a live value to the JSON-safe RemoteObject protocol. + * @param value - Value owned by this Client realm. + * @param options - Object group and serialization options. + * @param allocation - Optional operation that owns any newly retained handle. + * @returns A primitive value or opaque Client handle with display metadata. + */ + serialize( + value: unknown, + options: ClientRuntimeObjectOptions = {}, + allocation?: ClientObjectAllocation, + ): ClientRuntimeRemoteObject { + const primitive = serializePrimitive(value) + if (primitive !== undefined) return primitive + if (options.returnByValue === true) { + return { + descriptor: { + type: typeof value === 'function' ? 'function' : 'object', + value: serializeByValue(value), + description: describe(value), + }, + } + } + const type: RuntimeRemoteObjectType = typeof value === 'function' ? 'function' : typeof value === 'symbol' ? 'symbol' : 'object' + const subtype = type === 'object' ? subtypeOf(value) : undefined + const objectReference = identifyRealmObject(value) + return { + descriptor: { + type, + ...(subtype === undefined ? {} : { subtype }), + className: className(value), + description: describe(value), + ...(options.generatePreview === true && type === 'object' ? { preview: preview(value, type, subtype) } : {}), + }, + object: { handle: this.register(value, options.group, allocation) }, + ...(objectReference === undefined ? {} : { semanticReference: objectReference }), + } + } + + /** + * Release exactly one handle. Releasing an unknown handle is idempotent. + * @param handle - Client-local object handle. + */ + release(handle: ClientRemoteObjectHandle): void { + const object = this.objects.get(handle) + if (object === undefined) return + this.objects.delete(handle) + if (object.group === undefined) return + const members = this.groups.get(object.group) + members?.delete(handle) + if (members?.size === 0) this.groups.delete(object.group) + } + + /** + * Release every handle in one DevTools object group. + * @param group - DevTools object-group name. + */ + releaseGroup(group: string): void { + const members = this.groups.get(group) + if (members === undefined) return + for (const handle of members) this.objects.delete(handle) + this.groups.delete(group) + } + + /** + * Discard exactly the handles allocated by one failed operation. + * @param allocation - Allocation returned by {@link beginAllocation}. + */ + rollback(allocation: ClientObjectAllocation): void { + const handles = this.allocations.get(allocation) + if (handles === undefined) return + this.allocations.delete(allocation) + for (const handle of handles) this.release(handle) + } + + /** Release the whole DevTools session. */ + clear(): void { + this.objects.clear() + this.groups.clear() + this.allocations.clear() + } + + private register( + value: unknown, + group: string | undefined, + allocation: ClientObjectAllocation | undefined, + ): ClientRemoteObjectHandle { + if (this.objects.size >= this.maxObjects) { + throw new ClientRuntimeExecutionError('result-too-large', `Client Runtime retained-object limit ${String(this.maxObjects)} reached`) + } + const ordinal = this.nextOrdinal++ + const handle = inspectorId<'ClientRemoteObjectHandle'>(`object-${String(ordinal)}`, 'handle') + this.objects.set(handle, { value, group }) + if (allocation !== undefined) this.allocations.get(allocation)?.add(handle) + if (group !== undefined) { + let members = this.groups.get(group) + if (members === undefined) { + members = new Set() + this.groups.set(group, members) + } + members.add(handle) + } + return handle + } +} + +function serializePrimitive(value: unknown): ClientRuntimeRemoteObject | undefined { + if (value === undefined) return { descriptor: { type: 'undefined' } } + if (value === null) return { descriptor: { type: 'object', subtype: 'null', value: null } } + if (typeof value === 'string') return { descriptor: { type: 'string', value } } + if (typeof value === 'boolean') return { descriptor: { type: 'boolean', value } } + if (typeof value === 'bigint') { + const text = `${String(value)}n` + return { descriptor: { type: 'bigint', unserializableValue: text, description: text } } + } + if (typeof value !== 'number') return undefined + if (Number.isFinite(value) && !Object.is(value, -0)) { + return { descriptor: { type: 'number', value, description: String(value) } } + } + const text = Object.is(value, -0) ? '-0' : String(value) + return { descriptor: { type: 'number', unserializableValue: text, description: text } } +} + +function serializeByValue(value: unknown): InspectorJsonValue { + let serialized: unknown + try { + serialized = JSON.stringify(value) + } catch (error) { + throw new ClientRuntimeExecutionError('unsupported', `Value cannot be returned by value: ${renderError(error)}`) + } + if (typeof serialized !== 'string') throw new ClientRuntimeExecutionError('unsupported', 'Value cannot be returned by value') + const result = JSON.parse(serialized) as unknown + if (!isJsonValue(result)) throw new ClientRuntimeExecutionError('unsupported', 'Value is outside the JSON value set') + return result +} + +function preview( + value: unknown, + type: RuntimeRemoteObjectType, + subtype: RuntimeRemoteObjectSubtype | undefined, +): RuntimeObjectPreview { + const properties: RuntimePropertyPreview[] = [] + let overflow = false + if ((typeof value === 'object' && value !== null) || typeof value === 'function') { + let keys: readonly PropertyKey[] = [] + try { + keys = Reflect.ownKeys(value) + } catch { + overflow = true + } + for (const key of keys) { + if (properties.length === 5) { + overflow = true + break + } + let descriptor: PropertyDescriptor | undefined + try { + descriptor = Reflect.getOwnPropertyDescriptor(value, key) + } catch { + continue + } + if (descriptor === undefined) continue + if (!('value' in descriptor)) { + properties.push({ name: String(key), type: 'accessor' }) + continue + } + const propertyType = remoteType(descriptor.value) + const propertySubtype = propertyType === 'object' ? subtypeOf(descriptor.value) : undefined + properties.push({ + name: String(key), + type: propertyType, + value: previewText(descriptor.value), + ...(propertySubtype === undefined ? {} : { subtype: propertySubtype }), + }) + } + } + return { + type, + ...(subtype === undefined ? {} : { subtype }), + description: describe(value), + overflow, + properties, + } +} + +function remoteType(value: unknown): RuntimeRemoteObjectType { + if (value === null) return 'object' + return typeof value +} + +function subtypeOf(value: unknown): RuntimeRemoteObjectSubtype | undefined { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + if (ArrayBuffer.isView(value)) return value instanceof DataView ? 'dataview' : 'typedarray' + if (typeof value !== 'object') return undefined + for (const [prototype, subtype] of SUBTYPES_BY_PROTOTYPE) { + if (inheritsFrom(value, prototype)) return subtype + } + return undefined +} + +function className(value: unknown): string { + if (typeof value === 'function') return functionName(value) + if (typeof value === 'symbol') return 'Symbol' + if (typeof value !== 'object' || value === null) return 'Object' + const visited = new Set() + let prototype = prototypeOf(value) + while (prototype !== null && visited.size < MAX_CLASS_PROTOTYPE_DEPTH && !visited.has(prototype)) { + visited.add(prototype) + const constructor = Reflect.getOwnPropertyDescriptor(prototype, 'constructor') + const candidate: unknown = constructor !== undefined && 'value' in constructor ? constructor.value : undefined + if (typeof candidate === 'function') { + return functionName(candidate) + } + prototype = prototypeOf(prototype) + } + return 'Object' +} + +function describe(value: unknown): string { + if (typeof value === 'function') { + try { + return Function.prototype.toString.call(value) + } catch { + return functionName(value) + } + } + const subtype = subtypeOf(value) + if (subtype === 'array') { + const descriptor = Reflect.getOwnPropertyDescriptor(value as object, 'length') + const length: unknown = descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined + return `Array(${typeof length === 'number' ? String(length) : '?'})` + } + if (subtype === 'error') { + const stack = ownString(value as object, 'stack') + if (stack !== undefined) return stack + const name = ownString(value as object, 'name') ?? className(value) + const message = ownString(value as object, 'message') + return message === undefined || message.length === 0 ? name : `${name}: ${message}` + } + if (subtype === 'date') { + try { + return Date.prototype.toString.call(value) + } catch { + return 'Date' + } + } + if (subtype === 'regexp') { + try { + return RegExp.prototype.toString.call(value) + } catch { + return 'RegExp' + } + } + return className(value) +} + +function previewText(value: unknown): string { + if (typeof value === 'string') return value.slice(0, 100) + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' || typeof value === 'symbol') { + return String(value) + } + if (value === null) return 'null' + if (value === undefined) return 'undefined' + return describe(value).slice(0, 100) +} + +function functionName(value: object): string { + try { + const descriptor = Reflect.getOwnPropertyDescriptor(value, 'name') + const name: unknown = descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined + return typeof name === 'string' && name.length > 0 ? name : 'Function' + } catch { + return 'Function' + } +} + +function prototypeOf(value: object): object | null { + try { + return Reflect.getPrototypeOf(value) + } catch { + return null + } +} + +function inheritsFrom(value: object, expected: object): boolean { + const visited = new Set() + let current = prototypeOf(value) + while (current !== null && visited.size < MAX_CLASS_PROTOTYPE_DEPTH && !visited.has(current)) { + if (current === expected) return true + visited.add(current) + current = prototypeOf(current) + } + return false +} + +function ownString(value: object, key: string): string | undefined { + try { + const descriptor = Reflect.getOwnPropertyDescriptor(value, key) + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'string' + ? descriptor.value + : undefined + } catch { + return undefined + } +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +const SUBTYPES_BY_PROTOTYPE: readonly (readonly [object, RuntimeRemoteObjectSubtype])[] = [ + [RegExp.prototype, 'regexp'], + [Date.prototype, 'date'], + [Map.prototype, 'map'], + [Set.prototype, 'set'], + [WeakMap.prototype, 'weakmap'], + [WeakSet.prototype, 'weakset'], + [Error.prototype, 'error'], + [Promise.prototype, 'promise'], + [ArrayBuffer.prototype, 'arraybuffer'], + [DataView.prototype, 'dataview'], +] diff --git a/packages/experimental/inspector/src/client/cdp/profiler.ts b/packages/experimental/inspector/src/client/cdp/profiler.ts new file mode 100644 index 0000000000..d303fe7acc --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/profiler.ts @@ -0,0 +1,11 @@ +/** Client CPU profiling is not exposed by the source bridge. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe unavailable browser-side CPU profiling. + * @returns No source capability for Client CPU profiling. + */ +export function profilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/client/cdp/properties.ts b/packages/experimental/inspector/src/client/cdp/properties.ts new file mode 100644 index 0000000000..ceb0faa6ae --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/properties.ts @@ -0,0 +1,160 @@ +/** Lazy Client property enumeration for `Runtime.getProperties`. */ + +import type { + ClientRuntimeGetPropertiesCommand, + ClientRuntimeInternalPropertyDescriptor, + ClientRuntimePropertyDescriptor, +} from '../../shared/bridge/messages/runtime/index.ts' +import { ClientRuntimeExecutionError } from './errors.ts' +import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts' + +/** + * Read property descriptors without invoking getters. + * @param objects - Object table that owns the requested handle. + * @param command - Validated property request. + * @param maxProperties - Maximum descriptors returned by this operation. + * @param allocation - Current operation's object-allocation identity. + * @returns Own or inherited descriptors and the immediate prototype. + */ +export function getClientProperties( + objects: ClientObjectStore, + command: ClientRuntimeGetPropertiesCommand, + maxProperties: number, + allocation: ClientObjectAllocation, +): { + readonly properties: readonly ClientRuntimePropertyDescriptor[] + readonly internalProperties?: readonly ClientRuntimeInternalPropertyDescriptor[] +} { + const raw = objects.get(command.handle) + if (!isObjectLike(raw)) return { properties: [] } + const value: object = typeof raw === 'symbol' ? Symbol.prototype : raw + const group = objects.group(command.handle) + const properties: ClientRuntimePropertyDescriptor[] = [] + const seen = new Set() + const visited = new Set() + let owner: object | null = value + let own = true + + while (owner !== null) { + if (visited.has(owner) || visited.size >= maxProperties) { + throw new ClientRuntimeExecutionError('result-too-large', 'Client prototype traversal exceeded its configured limit') + } + visited.add(owner) + const keys = readKeys(owner) + for (const key of keys) { + if (seen.has(key)) continue + seen.add(key) + if (command.nonIndexedPropertiesOnly === true && typeof key === 'string' && isArrayIndex(key)) continue + const descriptor = readDescriptor(owner, key) + if (descriptor === undefined) continue + if (command.accessorPropertiesOnly === true && 'value' in descriptor) continue + if (properties.length >= maxProperties) { + throw new ClientRuntimeExecutionError( + 'result-too-large', + `Client property result exceeds the configured ${String(maxProperties)}-property limit`, + ) + } + properties.push(toRemoteDescriptor( + objects, + key, + descriptor, + group, + own, + command.generatePreview === true, + allocation, + )) + } + if (command.ownProperties === true) break + owner = readPrototype(owner) + own = false + } + + if (command.accessorPropertiesOnly === true) return { properties } + const prototype = readPrototype(value) + const internalProperties: ClientRuntimeInternalPropertyDescriptor[] = prototype === null + ? [] + : [{ + name: '[[Prototype]]', + value: objects.serialize(prototype, remoteOptions(group, command.generatePreview), allocation), + }] + return { properties, internalProperties } +} + +function toRemoteDescriptor( + objects: ClientObjectStore, + key: PropertyKey, + descriptor: PropertyDescriptor, + group: string | undefined, + own: boolean, + generatePreview: boolean, + allocation: ClientObjectAllocation, +): ClientRuntimePropertyDescriptor { + const common = { + name: typeof key === 'symbol' ? key.description ?? String(key) : String(key), + configurable: descriptor.configurable ?? false, + enumerable: descriptor.enumerable ?? false, + isOwn: own, + ...(typeof key === 'symbol' ? { symbol: objects.serialize(key, remoteOptions(group), allocation) } : {}), + } + if ('value' in descriptor) { + return { + ...common, + value: objects.serialize(descriptor.value, remoteOptions(group, generatePreview), allocation), + writable: descriptor.writable ?? false, + } + } + const getter = Reflect.get(descriptor, 'get') as (() => unknown) | undefined + const setter = Reflect.get(descriptor, 'set') as ((value: unknown) => void) | undefined + return { + ...common, + ...(getter === undefined ? {} : { get: objects.serialize(getter, remoteOptions(group), allocation) }), + ...(setter === undefined ? {} : { set: objects.serialize(setter, remoteOptions(group), allocation) }), + } +} + +function readKeys(value: object): readonly PropertyKey[] { + try { + return Reflect.ownKeys(value) + } catch (error) { + throw new ClientRuntimeExecutionError('internal-error', `Cannot enumerate Client object: ${renderError(error)}`) + } +} + +function readDescriptor(value: object, key: PropertyKey): PropertyDescriptor | undefined { + try { + return Reflect.getOwnPropertyDescriptor(value, key) + } catch (error) { + throw new ClientRuntimeExecutionError('internal-error', `Cannot read Client property ${String(key)}: ${renderError(error)}`) + } +} + +function readPrototype(value: object): object | null { + try { + return Object.getPrototypeOf(value) as object | null + } catch (error) { + throw new ClientRuntimeExecutionError('internal-error', `Cannot read Client object prototype: ${renderError(error)}`) + } +} + +function isObjectLike(value: unknown): value is object | symbol { + return (typeof value === 'object' && value !== null) || typeof value === 'function' || typeof value === 'symbol' +} + +function isArrayIndex(value: string): boolean { + const number = Number(value) + return Number.isInteger(number) && number >= 0 && number < 4_294_967_295 && String(number) === value +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function remoteOptions(group: string | undefined, generatePreview?: boolean): { + readonly group?: string + readonly generatePreview?: boolean +} { + return { + ...(group === undefined ? {} : { group }), + ...(generatePreview === undefined ? {} : { generatePreview }), + } +} diff --git a/packages/experimental/inspector/src/client/cdp/runtime.ts b/packages/experimental/inspector/src/client/cdp/runtime.ts new file mode 100644 index 0000000000..b72a73e2fc --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/runtime.ts @@ -0,0 +1,423 @@ +/** Client-realm executor for the typed Runtime command protocol. */ + +import type { + ClientCallArgument, + ClientRuntimeCapability, + ClientRuntimeCommand, + ClientRuntimeCompletion, + ClientRuntimeError, + ClientRuntimeExceptionDetails, + ClientRuntimeRequestFrame, + ClientRuntimeResponseFrame, + ClientRuntimeResult, + ClientRuntimeRemoteObject, +} from '../../shared/bridge/messages/runtime/index.ts' +import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts' +import { isJsonValue, jsonByteLength } from '../../shared/json.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' +import { ClientRuntimeExecutionError } from './errors.ts' +import type { RuntimeConsoleBackendEvent, RuntimeConsoleType, RuntimeStackTrace } from '../../shared/cdp/index.ts' +import { ClientObjectStore, type ClientObjectAllocation } from './objects.ts' +import { getClientProperties } from './properties.ts' +import { clientErrorStack, type ClientScriptKeyResolver } from './stack.ts' + +const MAX_RUNTIME_ERROR_MESSAGE_LENGTH = 2_048 + +/** + * Describe browser-side Runtime execution. + * @param origin - Origin assigned to the synthetic execution context. + * @returns The Runtime capability advertised by a browser Client source. + */ +export function runtimeBridgeCapability(origin: string): ClientRuntimeCapability { + return { type: 'client-runtime', origin } +} + +/** Client-side limits injected by the Host deployment. */ +export interface ClientRuntimeLimits { + readonly maxObjectsPerSession: number + readonly maxPropertiesPerResult: number + readonly maxResponseBytes: number +} + +/** Executes Runtime requests while isolating object handles by DevTools session. */ +export class ClientRuntimeExecutor { + private readonly sessions = new Map() + + constructor( + private readonly limits: ClientRuntimeLimits, + private readonly resolveScript: ClientScriptKeyResolver = () => undefined, + ) {} + + /** + * Execute one request and preserve its source, generation, session, and request identities. + * @param frame - Validated command envelope from the Worker. + * @returns A success or transport-error response for the same request. + */ + async execute(frame: ClientRuntimeRequestFrame): Promise { + const session = this.session(frame.sessionId) + const allocation = session.beginAllocation() + try { + const result = await session.execute(frame.command, allocation) + const response = responseFrame(frame, { ok: true, result }) + if (!isJsonValue(response) || jsonByteLength(response) > this.limits.maxResponseBytes) { + session.rollback(allocation) + return responseFrame(frame, { + ok: false, + error: { code: 'result-too-large', message: 'Client Runtime result exceeds the source-frame byte limit' }, + }) + } + session.commitAllocation(allocation) + return response + } catch (error) { + session.rollback(allocation) + return responseFrame(frame, { ok: false, error: runtimeError(error) }) + } + } + + /** + * Release all values retained for one closed DevTools connection. + * @param sessionId - Runtime session owned by that DevTools connection. + */ + closeSession(sessionId: ClientRuntimeSessionId): void { + this.sessions.get(sessionId)?.close() + this.sessions.delete(sessionId) + } + + /** + * Release one object group without closing the surrounding Runtime session. + * @param sessionId - Session that owns the retained objects. + * @param group - Object-group name to release. + */ + releaseObjectGroup(sessionId: ClientRuntimeSessionId, group: string): void { + this.sessions.get(sessionId)?.releaseObjectGroup(group) + } + + /** + * Serialize one Console call for a specific DevTools Runtime session. + * @param sessionId - Session receiving the Console event. + * @param type - Console API operation. + * @param values - Original arguments from the page call. + * @param timestamp - Epoch timestamp in milliseconds. + * @param stackTrace - Browser call frames captured before deferred delivery. + * @returns A wire-safe event whose object handles belong only to this session. + */ + consoleEvent( + sessionId: ClientRuntimeSessionId, + type: RuntimeConsoleType, + values: readonly unknown[], + timestamp: number, + stackTrace?: RuntimeStackTrace, + ): RuntimeConsoleBackendEvent | undefined { + const session = this.session(sessionId) + const allocation = session.beginAllocation() + try { + const event: RuntimeConsoleBackendEvent = { + type: 'console-api', + event: { + type, + arguments: session.serializeAll(values, 'console', allocation), + timestamp, + ...(stackTrace === undefined ? {} : { stackTrace }), + }, + } + if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) { + session.rollback(allocation) + return undefined + } + session.commitAllocation(allocation) + return event + } catch (error) { + session.rollback(allocation) + throw error + } + } + + /** + * Serialize one uncaught Client exception for a DevTools Runtime session. + * @param sessionId - Session receiving the exception event. + * @param error - Thrown or rejected value. + * @param timestamp - Epoch timestamp in milliseconds. + * @param stackTrace - Browser call frames attached to the failure. + * @returns A wire-safe exception event. + */ + exceptionEvent( + sessionId: ClientRuntimeSessionId, + error: unknown, + timestamp: number, + stackTrace?: RuntimeStackTrace, + ): RuntimeConsoleBackendEvent | undefined { + const session = this.session(sessionId) + const allocation = session.beginAllocation() + try { + const event: RuntimeConsoleBackendEvent = { + type: 'exception', + event: { + timestamp, + details: session.describeException(error, 'console', stackTrace, allocation), + }, + } + if (!isJsonValue(event) || jsonByteLength(event) + 4_096 > this.limits.maxResponseBytes) { + session.rollback(allocation) + return undefined + } + session.commitAllocation(allocation) + return event + } catch (serializationError) { + session.rollback(allocation) + throw serializationError + } + } + + /** Release all sessions when a source generation ends or reconnects. */ + reset(): void { + for (const session of this.sessions.values()) session.close() + this.sessions.clear() + } + + private session(sessionId: ClientRuntimeSessionId): ClientRuntimeSession { + let session = this.sessions.get(sessionId) + if (session === undefined) { + session = new ClientRuntimeSession( + this.limits.maxObjectsPerSession, + this.limits.maxPropertiesPerResult, + this.resolveScript, + ) + this.sessions.set(sessionId, session) + } + return session + } +} + +class ClientRuntimeSession { + private readonly objects: ClientObjectStore + + constructor( + maxObjects: number, + private readonly maxProperties: number, + private readonly resolveScript: ClientScriptKeyResolver, + ) { + this.objects = new ClientObjectStore(maxObjects) + } + + beginAllocation(): ClientObjectAllocation { + return this.objects.beginAllocation() + } + + commitAllocation(allocation: ClientObjectAllocation): void { + this.objects.commitAllocation(allocation) + } + + rollback(allocation: ClientObjectAllocation): void { + this.objects.rollback(allocation) + } + + async execute(command: ClientRuntimeCommand, allocation: ClientObjectAllocation): Promise { + switch (command.op) { + case 'evaluate': + return { op: command.op, completion: await this.evaluate(command, allocation) } + case 'get-properties': { + const result = getClientProperties(this.objects, command, this.maxProperties, allocation) + return { op: command.op, ...result } + } + case 'call-function': + return { op: command.op, completion: await this.callFunction(command, allocation) } + case 'await-promise': + return { op: command.op, completion: await this.awaitPromise(command, allocation) } + case 'release-object': + this.objects.release(command.handle) + return { op: command.op } + case 'release-object-group': + this.releaseObjectGroup(command.objectGroup) + return { op: command.op } + case 'global-lexical-scope-names': + return { op: command.op, names: [] } + default: + return assertNever(command) + } + } + + close(): void { + this.objects.clear() + } + + releaseObjectGroup(group: string): void { + this.objects.releaseGroup(group) + } + + serializeAll( + values: readonly unknown[], + group: string, + allocation: ClientObjectAllocation, + ): ClientRuntimeRemoteObject[] { + return values.map(value => this.objects.serialize(value, { group, generatePreview: true }, allocation)) + } + + describeException( + error: unknown, + group: string | undefined, + stackTrace?: RuntimeStackTrace, + allocation?: ClientObjectAllocation, + ): ClientRuntimeExceptionDetails { + const options = { ...(group === undefined ? {} : { group }) } + const resolvedStackTrace = stackTrace ?? clientErrorStack(error, this.resolveScript) + const firstFrame = resolvedStackTrace?.callFrames[0] + return { + text: 'Uncaught', + lineNumber: firstFrame?.lineNumber ?? 0, + columnNumber: firstFrame?.columnNumber ?? 0, + ...(firstFrame === undefined ? clientUrl() : { url: firstFrame.url }), + ...(resolvedStackTrace === undefined ? {} : { stackTrace: resolvedStackTrace }), + exception: this.objects.serialize(error, options, allocation), + } + } + + private async evaluate( + command: Extract, + allocation: ClientObjectAllocation, + ): Promise { + let value: unknown + try { + value = globalThis.eval(command.expression) as unknown + if (command.awaitPromise === true) value = await awaitWithTimeout(value, command.timeoutMs) + } catch (error) { + if (error instanceof ClientRuntimeExecutionError) throw error + return this.exception(error, command.objectGroup, allocation) + } + return this.completion( + value, + allocation, + command.objectGroup, + command.generatePreview, + command.returnByValue, + ) + } + + private async callFunction( + command: Extract, + allocation: ClientObjectAllocation, + ): Promise { + const receiver = command.receiver === undefined ? globalThis : this.objects.get(command.receiver) + const inheritedGroup = command.receiver === undefined ? undefined : this.objects.group(command.receiver) + const group = command.objectGroup ?? inheritedGroup + const args = (command.arguments ?? []).map(argument => this.resolveArgument(argument)) + let value: unknown + try { + const fn = globalThis.eval(`(${command.functionDeclaration}\n)`) as unknown + if (typeof fn !== 'function') throw new TypeError('functionDeclaration did not evaluate to a function') + value = Reflect.apply(fn, receiver, args) + if (command.awaitPromise === true) value = await value + } catch (error) { + return this.exception(error, group, allocation) + } + return this.completion(value, allocation, group, command.generatePreview, command.returnByValue) + } + + private async awaitPromise( + command: Extract, + allocation: ClientObjectAllocation, + ): Promise { + const group = this.objects.group(command.promise) + let value: unknown + try { + value = await this.objects.get(command.promise) + } catch (error) { + if (error instanceof ClientRuntimeExecutionError) throw error + return this.exception(error, group, allocation) + } + return this.completion(value, allocation, group, command.generatePreview, command.returnByValue) + } + + private resolveArgument(argument: ClientCallArgument): unknown { + switch (argument.kind) { + case 'value': return argument.value + case 'object': return this.objects.get(argument.handle) + case 'undefined': return undefined + case 'unserializable': return parseUnserializable(argument.value) + default: return assertNever(argument) + } + } + + private exception( + error: unknown, + group: string | undefined, + allocation: ClientObjectAllocation, + ): ClientRuntimeCompletion { + const options = { ...(group === undefined ? {} : { group }) } + const details = this.describeException(error, group, undefined, allocation) + return { result: this.objects.serialize(error, options, allocation), exceptionDetails: details } + } + + private completion( + value: unknown, + allocation: ClientObjectAllocation, + group: string | undefined, + generatePreview: boolean | undefined, + returnByValue: boolean | undefined, + ): ClientRuntimeCompletion { + return { + result: this.objects.serialize(value, { + ...(group === undefined ? {} : { group }), + ...(generatePreview === undefined ? {} : { generatePreview }), + ...(returnByValue === undefined ? {} : { returnByValue }), + }, allocation), + } + } +} + +function responseFrame( + request: ClientRuntimeRequestFrame, + outcome: ClientRuntimeResponseFrame['outcome'], +): ClientRuntimeResponseFrame { + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/response', + sourceId: request.sourceId, + generation: request.generation, + sessionId: request.sessionId, + requestId: request.requestId, + outcome, + } +} + +function runtimeError(error: unknown): ClientRuntimeError { + const code = error instanceof ClientRuntimeExecutionError ? error.code : 'internal-error' + const message = error instanceof Error ? error.message : String(error) + return { code, message: message.slice(0, MAX_RUNTIME_ERROR_MESSAGE_LENGTH) } +} + +function parseUnserializable(value: string): unknown { + if (value === 'NaN') return Number.NaN + if (value === 'Infinity') return Number.POSITIVE_INFINITY + if (value === '-Infinity') return Number.NEGATIVE_INFINITY + if (value === '-0') return -0 + if (/^-?(?:0|[1-9]\d*)n$/u.test(value)) return BigInt(value.slice(0, -1)) + throw new ClientRuntimeExecutionError('invalid-request', `Unsupported unserializable value ${JSON.stringify(value)}`) +} + +function clientUrl(): { readonly url?: string } { + const location = Reflect.get(globalThis, 'location') as unknown + if (typeof location !== 'object' || location === null) return {} + const href = Reflect.get(location, 'href') as unknown + return typeof href === 'string' ? { url: href } : {} +} + +async function awaitWithTimeout(value: unknown, timeoutMs: number | undefined): Promise { + if (timeoutMs === undefined) return await value + let timer: ReturnType | undefined + try { + return await Promise.race([ + Promise.resolve(value), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`)) + }, timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Client Runtime variant: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/client/cdp/sources.ts b/packages/experimental/inspector/src/client/cdp/sources.ts new file mode 100644 index 0000000000..23d63909a9 --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/sources.ts @@ -0,0 +1,233 @@ +/** Browser-side catalog for the Inspector Client bundle and its source map. */ + +import { bytesToBase64 } from '@deepseek-ai/dsh-util-crypto' +import type { + ClientScriptDescriptor, + ClientSourceCommand, + ClientSourceError, + ClientSourceResult, + ClientSourcesCapability, +} from '../../shared/bridge/messages/sources/index.ts' +import { inspectorId } from '../../shared/identity.ts' +import type { RuntimeScriptKey } from '../../shared/cdp/ids.ts' + +const PACKAGE_ID = '@deepseek-ai/dsh-experimental-inspector' +const CLIENT_SCRIPT_KEY = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey') + +/** + * Describe browser-side source access. + * @param available - Whether the Client bundle was discovered. + * @returns The Sources capability when this Client discovered its bundle. + */ +export function sourcesBridgeCapability(available: boolean): ClientSourcesCapability | undefined { + return available ? { type: 'client-sources' } : undefined +} + +/** One lazily loaded browser script exposed by a Client source catalog. */ +export interface ClientSourceAsset { + readonly scriptKey: RuntimeScriptKey + readonly url: string + readonly hash: string + readonly sourceMapUrl?: string + readonly isModule?: boolean + loadSource(): Promise + loadSourceMap?(): Promise +} + +interface LoadedAsset { + readonly asset: ClientSourceAsset + source?: Promise + sourceBytes?: Promise + sourceMapBytes?: Promise +} + +/** Deliberate error serialized by the Client source transport. */ +export class ClientSourceCatalogError extends Error { + constructor(readonly code: ClientSourceError['code'], message: string) { + super(message) + } +} + +/** Executes bounded, read-only operations over Client script assets. */ +export class ClientSourceCatalog { + private readonly assets = new Map() + + constructor(assets: readonly ClientSourceAsset[]) { + for (const asset of assets) { + if (this.assets.has(asset.scriptKey)) { + throw new Error(`inspector: duplicate Client script key ${asset.scriptKey}`) + } + this.assets.set(asset.scriptKey, { asset }) + } + } + + /** + * Resolve a stack-frame URL to this catalog's local script key. + * @param url - Absolute or page-relative stack-frame URL. + * @returns The matching script key when the URL belongs to this catalog. + */ + scriptKeyForUrl(url: string): RuntimeScriptKey | undefined { + const normalized = normalizedUrl(url) + for (const entry of this.assets.values()) { + if (normalizedUrl(entry.asset.url) === normalized) return entry.asset.scriptKey + } + return undefined + } + + /** + * Execute one validated source operation. + * @param command - Read-only catalog command. + * @param maxContentBytes - Maximum encoded bytes admitted for one asset. + * @returns Script metadata or one bounded content chunk. + */ + async execute(command: ClientSourceCommand, maxContentBytes: number): Promise { + if (command.op === 'list-scripts') { + return { + op: command.op, + scripts: await Promise.all([...this.assets.values()].map(async entry => this.describe(entry, maxContentBytes))), + } + } + const entry = this.assets.get(command.scriptKey) + if (entry === undefined) throw new ClientSourceCatalogError('script-not-found', 'Client script is not available') + const bytes = command.content === 'source' + ? await this.sourceBytes(entry, maxContentBytes) + : await this.sourceMapBytes(entry, maxContentBytes) + if (bytes === undefined) { + return { + op: command.op, + scriptKey: command.scriptKey, + content: command.content, + available: false, + } + } + if (command.offset > bytes.byteLength) { + throw new ClientSourceCatalogError('invalid-request', 'Client source chunk offset exceeds content length') + } + const nextOffset = Math.min(bytes.byteLength, command.offset + command.maxBytes) + return { + op: command.op, + scriptKey: command.scriptKey, + content: command.content, + available: true, + offset: command.offset, + nextOffset, + data: bytesToBase64(bytes.subarray(command.offset, nextOffset)), + eof: nextOffset === bytes.byteLength, + } + } + + private async describe(entry: LoadedAsset, maxContentBytes: number): Promise { + const source = await this.source(entry, maxContentBytes) + const newline = source.lastIndexOf('\n') + return { + scriptKey: entry.asset.scriptKey, + url: entry.asset.url, + hash: entry.asset.hash, + buildId: '', + ...(entry.asset.sourceMapUrl === undefined ? {} : { sourceMapUrl: entry.asset.sourceMapUrl }), + startLine: 0, + startColumn: 0, + endLine: countNewlines(source), + endColumn: newline === -1 ? source.length : source.length - newline - 1, + ...(entry.asset.isModule === undefined ? {} : { isModule: entry.asset.isModule }), + length: source.length, + } + } + + private source(entry: LoadedAsset, maxContentBytes: number): Promise { + entry.source ??= entry.asset.loadSource().catch((error: unknown) => { + throw new ClientSourceCatalogError('load-failed', `Cannot load Client script: ${renderError(error)}`) + }) + return entry.source.then((source) => { + if (new TextEncoder().encode(source).byteLength > maxContentBytes) { + throw new ClientSourceCatalogError('result-too-large', 'Client script exceeds the configured content limit') + } + return source + }) + } + + private sourceBytes(entry: LoadedAsset, maxContentBytes: number): Promise { + entry.sourceBytes ??= this.source(entry, maxContentBytes).then(source => new TextEncoder().encode(source)) + return entry.sourceBytes + } + + private sourceMapBytes(entry: LoadedAsset, maxContentBytes: number): Promise { + if (entry.asset.loadSourceMap === undefined) return Promise.resolve(undefined) + entry.sourceMapBytes ??= entry.asset.loadSourceMap().then(value => + value === undefined ? undefined : new TextEncoder().encode(value), + ).catch((error: unknown) => { + throw new ClientSourceCatalogError('load-failed', `Cannot load Client source map: ${renderError(error)}`) + }) + return entry.sourceMapBytes.then((bytes) => { + if (bytes !== undefined && bytes.byteLength > maxContentBytes) { + throw new ClientSourceCatalogError('result-too-large', 'Client source map exceeds the configured content limit') + } + return bytes + }) + } +} + +/** + * Discover this package's bundle URL from the Host-injected web boot graph. + * @returns A lazy catalog, or `undefined` outside the assembled web application. + */ +export function discoverInspectorClientSourceCatalog(): ClientSourceCatalog | undefined { + const graph = Reflect.get(globalThis, '__DSH_BOOT__') as unknown + if (typeof graph !== 'object' || graph === null) return undefined + const entries = Reflect.get(graph, 'entries') as unknown + if (!Array.isArray(entries)) return undefined + const row = entries.find((value) => { + if (typeof value !== 'object' || value === null) return false + return Reflect.get(value, 'id') === PACKAGE_ID + }) as Record | undefined + if (row === undefined || typeof row.url !== 'string' || typeof row.rev !== 'string') return undefined + const base = browserLocation() + if (base === undefined) return undefined + const sourceUrl = new URL(row.url, base) + const sourceMapUrl = new URL(sourceUrl.href) + sourceMapUrl.pathname = `${sourceMapUrl.pathname}.map` + return new ClientSourceCatalog([{ + scriptKey: CLIENT_SCRIPT_KEY, + url: sourceUrl.href, + hash: row.rev, + sourceMapUrl: sourceMapUrl.href, + isModule: false, + loadSource: async () => fetchText(sourceUrl.href), + loadSourceMap: async () => fetchText(sourceMapUrl.href), + }]) +} + +async function fetchText(url: string): Promise { + const response = await fetch(url) + if (!response.ok) throw new Error(`${String(response.status)} ${response.statusText}`) + return response.text() +} + +function browserLocation(): string | undefined { + const location = Reflect.get(globalThis, 'location') as unknown + if (typeof location !== 'object' || location === null) return undefined + const href = Reflect.get(location, 'href') as unknown + return typeof href === 'string' ? href : undefined +} + +function countNewlines(value: string): number { + let count = 0 + for (let index = 0; index < value.length; index++) { + if (value.charCodeAt(index) === 10) count++ + } + return count +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function normalizedUrl(value: string): string { + try { + const url = new URL(value, browserLocation()) + url.hash = '' + return url.href + } catch { + return value + } +} diff --git a/packages/experimental/inspector/src/client/cdp/stack.ts b/packages/experimental/inspector/src/client/cdp/stack.ts new file mode 100644 index 0000000000..5386bbc9ef --- /dev/null +++ b/packages/experimental/inspector/src/client/cdp/stack.ts @@ -0,0 +1,78 @@ +/** Browser stack parsing for realm-neutral Runtime and Console events. */ + +import type { RuntimeScriptKey } from '../../shared/cdp/ids.ts' +import type { RuntimeCallFrame, RuntimeStackTrace } from '../../shared/cdp/index.ts' + +/** Resolve a browser stack-frame URL to a Client catalog script key. */ +export type ClientScriptKeyResolver = (url: string) => RuntimeScriptKey | undefined + +/** + * Capture the caller stack of a wrapped Client Console method. + * @param resolveScript - Resolver for Client catalog script keys. + * @returns Parsed call frames when the browser supplies a stack. + */ +export function captureClientConsoleStack(resolveScript: ClientScriptKeyResolver): RuntimeStackTrace | undefined { + return parseClientStack(new Error().stack, resolveScript, 3) +} + +/** + * Parse the stack attached to an uncaught Client value when available. + * @param value - Thrown or rejected value. + * @param resolveScript - Resolver for Client catalog script keys. + * @returns Parsed call frames when the value has a recognized stack string. + */ +export function clientErrorStack( + value: unknown, + resolveScript: ClientScriptKeyResolver = () => undefined, +): RuntimeStackTrace | undefined { + if (typeof value !== 'object' || value === null) return undefined + let stack: unknown + try { + stack = Reflect.get(value, 'stack') as unknown + } catch { + // A thrown proxy or stack getter cannot replace the original JavaScript exception. + return undefined + } + return typeof stack === 'string' ? parseClientStack(stack, resolveScript, 0) : undefined +} + +/** + * Parse V8- and Firefox-style textual frames into the common stack model. + * @param stack - Browser stack text. + * @param resolveScript - Resolver for Client catalog script keys. + * @param skipFrames - Parsed observer frames omitted from the result. + * @returns Parsed call frames, or `undefined` when none remain. + */ +export function parseClientStack( + stack: string | undefined, + resolveScript: ClientScriptKeyResolver, + skipFrames: number, +): RuntimeStackTrace | undefined { + if (stack === undefined) return undefined + const frames: RuntimeCallFrame[] = [] + for (const line of stack.split('\n')) { + const frame = parseFrame(line, resolveScript) + if (frame !== undefined) frames.push(frame) + } + const callFrames = frames.slice(skipFrames) + return callFrames.length === 0 ? undefined : { callFrames } +} + +function parseFrame(line: string, resolveScript: ClientScriptKeyResolver): RuntimeCallFrame | undefined { + const chrome = /^\s*at\s+(?:(.*?)\s+\()?(.+):(\d+):(\d+)\)?$/u.exec(line) + const firefox = chrome === null ? /^(.*?)@(.+):(\d+):(\d+)$/u.exec(line) : null + const match = chrome ?? firefox + if (match === null) return undefined + const url = match[2] + const lineNumber = Number(match[3]) - 1 + const columnNumber = Number(match[4]) - 1 + if (url === undefined || !Number.isSafeInteger(lineNumber) || !Number.isSafeInteger(columnNumber)) return undefined + const scriptKey = resolveScript(url) + return { + functionName: match[1] ?? '', + ...(scriptKey === undefined ? {} : { scriptKey }), + url, + lineNumber, + columnNumber, + } +} diff --git a/packages/experimental/inspector/src/client/index.ts b/packages/experimental/inspector/src/client/index.ts new file mode 100644 index 0000000000..89788b09ce --- /dev/null +++ b/packages/experimental/inspector/src/client/index.ts @@ -0,0 +1,3 @@ +/** Browser Client entry for the experimental Inspector Cordis plugin. */ + +export * from './plugin.ts' diff --git a/packages/experimental/inspector/src/client/inspection/realm.ts b/packages/experimental/inspector/src/client/inspection/realm.ts new file mode 100644 index 0000000000..7271a94ea5 --- /dev/null +++ b/packages/experimental/inspector/src/client/inspection/realm.ts @@ -0,0 +1,37 @@ +/** Stable Client source identity with a fresh descriptor for each WebSocket generation. */ + +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { inspectorId } from '../../shared/identity.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { bridgeCapabilities } from '../cdp/index.ts' + +/** Owns one browser realm's stable source id across transport reconnects. */ +export class ClientRealmSource { + /** Logical source id retained across reconnecting transport generations. */ + readonly sourceId = inspectorId<'InspectorSourceId'>(`client-${randomUUID()}`, 'sourceId') + + constructor(private readonly label: string) {} + + /** + * Create the descriptor for one newly admitted transport generation. + * @param hasSources - Whether the built Client bundle is available for source reads. + * @returns A source descriptor with a fresh generation. + */ + connect(hasSources: boolean): InspectorSourceDescriptor { + return { + sourceId: this.sourceId, + generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'), + kind: 'client', + label: this.label, + timeOriginMs: performance.timeOrigin, + capabilities: bridgeCapabilities(clientOrigin(), hasSources), + } + } +} + +function clientOrigin(): string { + const location = Reflect.get(globalThis, 'location') as unknown + if (typeof location !== 'object' || location === null) return '' + const origin = Reflect.get(location, 'origin') as unknown + return typeof origin === 'string' ? origin : '' +} diff --git a/packages/experimental/inspector/src/client/plugin.ts b/packages/experimental/inspector/src/client/plugin.ts new file mode 100644 index 0000000000..5b619a4788 --- /dev/null +++ b/packages/experimental/inspector/src/client/plugin.ts @@ -0,0 +1,61 @@ +/** Client Cordis plugin that publishes browser observations directly to the Inspector Worker. */ + +import type { Context } from '@deepseek-ai/cordis' +import { parseInspectorClientBootstrap } from '../shared/bridge/control-codec.ts' +import { createInspectorService, type InspectorService as SharedInspectorService } from '../shared/service.ts' +import { publishCordisTree } from './inspection/cordis.ts' +import { startInspectorClient } from './bridge/controller.ts' + +export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts' +export type { + CordisRuntimeConnection, + CordisRuntimeContext, + CordisRuntimeFiber, + CordisRuntimeNode, + CordisRuntimeRealm, + CordisRuntimeSource, + CordisRuntimeTree, +} from '../shared/cordis/model.ts' + +/** Client-facing Inspector service backed by the shared implementation. */ +export interface InspectorService extends SharedInspectorService {} + +declare global { + /** Host-injected Inspector Client connection parameters. */ + var __DSH_INSPECTOR__: unknown +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Publish Client-realm observations and query the shared Inspector state. */ + inspector: InspectorService + } +} + +/** Cordis plugin name shared with the Host face. */ +export const name = 'experimental-inspector' + +/** This transport root has no Client service dependencies. */ +export const inject: string[] = [] + +/** Mount the Client source and shared `ctx.inspector` publishing API. */ +export function apply(ctx: Context): void { + const injected = globalThis.__DSH_INSPECTOR__ + if (injected === undefined) { + throw new Error('experimental inspector: Host bootstrap is missing') + } + const bootstrap = parseInspectorClientBootstrap(injected) + ctx.effect(() => { + const source = startInspectorClient(bootstrap) + const disposeCordis = publishCordisTree(ctx, source, { + maxNodes: bootstrap.maxCordisNodes, + maxBytes: bootstrap.maxFrameBytes - 4_096, + }) + const disposeService = ctx.provide('inspector', createInspectorService(source)) + return () => { + disposeService() + disposeCordis() + source.close() + } + }, 'experimental-inspector: Client source') +} diff --git a/packages/experimental/inspector/src/host/bridge/controller.ts b/packages/experimental/inspector/src/host/bridge/controller.ts new file mode 100644 index 0000000000..aa25dac90c --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/controller.ts @@ -0,0 +1,350 @@ +/** Host controller that owns the Inspector Worker and Host observation source. */ + +import { randomBytes, randomUUID } from 'node:crypto' +import { tmpdir } from 'node:os' +import { MessageChannel, Worker, type MessagePort, type WorkerOptions } from 'node:worker_threads' +import type { InspectorClientBootstrap, InspectorWorkerBoot, InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' +import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { installFetchObserver, NETWORK_TOPICS, type FetchObserver } from '../inspection/network.ts' +import { HostInspectorSource } from './transport.ts' +import { InspectorWorkerLifecycle } from './lifecycle.ts' + +const DEFAULT_MAX_REQUEST_BODY_BYTES = 8 * 1024 * 1024 +const DEFAULT_MAX_RESPONSE_BODY_BYTES = 32 * 1024 * 1024 +const DEFAULT_MAX_BODY_CHUNK_BYTES = 48 * 1024 +const DEFAULT_MAX_JOURNAL_BYTES = 256 * 1024 * 1024 +const DEFAULT_MAX_RETAINED_REQUESTS = 2_000 +const DEFAULT_MAX_SOURCE_FRAME_BYTES = 128 * 1024 +const DEFAULT_MAX_SOURCE_RECORDS_PER_FRAME = 128 +const DEFAULT_MAX_QUEUED_RECORDS = 2_048 +const DEFAULT_MAX_QUEUED_BYTES = 16 * 1024 * 1024 +const DEFAULT_STARTUP_TIMEOUT_MS = 10_000 +const DEFAULT_STOP_TIMEOUT_MS = 5_000 +const DEFAULT_CLIENT_RECONNECT_BASE_MS = 250 +const DEFAULT_CLIENT_RECONNECT_MAX_MS = 5_000 +const DEFAULT_CLIENT_RUNTIME_TIMEOUT_MS = 30_000 +const DEFAULT_QUERY_TIMEOUT_MS = 10_000 +const DEFAULT_MAX_CLIENT_RUNTIME_OBJECTS = 10_000 +const DEFAULT_MAX_CLIENT_RUNTIME_PROPERTIES = 2_000 +const DEFAULT_MAX_CLIENT_SOURCE_BYTES = 8 * 1024 * 1024 +const DEFAULT_MAX_CORDIS_NODES = 2_048 +const DEFAULT_MAX_DISCONNECTED_CORDIS_TREES = 8 + +/** User-facing Host options; every memory and lifecycle bound is configurable. */ +export interface InspectorOptions { + /** Loopback address used by the Worker HTTP and WebSocket endpoint. */ + readonly host?: '127.0.0.1' + /** First port to bind; occupied ports advance until one is available. */ + readonly port?: number + /** Additional exact browser origins admitted to the Client ingest socket. */ + readonly clientOrigins?: readonly string[] + /** Whether to observe calls made through the current global fetch function. */ + readonly captureFetch?: boolean + /** Maximum request-body prefix retained for one fetch. */ + readonly maxRequestBodyBytes?: number + /** Maximum response-body prefix retained for one fetch. */ + readonly maxResponseBodyBytes?: number + /** Maximum raw bytes encoded into one body observation. */ + readonly maxBodyChunkBytes?: number + /** Maximum total request and response body bytes retained by the Worker. */ + readonly maxJournalBytes?: number + /** Maximum active and completed fetch requests retained by the Worker. */ + readonly maxRetainedRequests?: number + /** Maximum encoded bytes accepted in one source transport frame. */ + readonly maxSourceFrameBytes?: number + /** Maximum observation records accepted in one source batch. */ + readonly maxSourceRecordsPerFrame?: number + /** Maximum records waiting in one producer queue. */ + readonly maxQueuedRecords?: number + /** Maximum encoded bytes waiting in one producer queue. */ + readonly maxQueuedBytes?: number + /** Maximum time allowed for the Worker to become ready. */ + readonly startupTimeoutMs?: number + /** Grace period before a stopping Worker is terminated. */ + readonly stopTimeoutMs?: number + /** Initial upper bound for randomized Client reconnect delay. */ + readonly clientReconnectBaseMs?: number + /** Maximum upper bound for randomized Client reconnect delay. */ + readonly clientReconnectMaxMs?: number + /** Deadline for one Worker-to-Client Runtime or Sources request. */ + readonly clientRuntimeTimeoutMs?: number + /** Deadline for one non-CDP semantic query. */ + readonly queryTimeoutMs?: number + /** Maximum live object handles retained per Client Runtime session. */ + readonly maxClientRuntimeObjects?: number + /** Maximum descriptors returned by one Client property request. */ + readonly maxClientRuntimeProperties?: number + /** Maximum encoded bytes read for one Client script or source map. */ + readonly maxClientSourceBytes?: number + /** Maximum Context and Fiber nodes retained in one realm snapshot. */ + readonly maxCordisNodes?: number + /** Disconnected Cordis snapshots retained after their live realm closes. */ + readonly maxDisconnectedCordisTrees?: number +} + +/** Fully resolved options used by one running Inspector. */ +export interface InspectorSpec { + readonly host: '127.0.0.1' + readonly port: number + readonly clientOrigins: readonly string[] + readonly captureFetch: boolean + readonly maxRequestBodyBytes: number + readonly maxResponseBodyBytes: number + readonly maxBodyChunkBytes: number + readonly maxJournalBytes: number + readonly maxRetainedRequests: number + readonly maxSourceFrameBytes: number + readonly maxSourceRecordsPerFrame: number + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly startupTimeoutMs: number + readonly stopTimeoutMs: number + readonly clientReconnectBaseMs: number + readonly clientReconnectMaxMs: number + readonly clientRuntimeTimeoutMs: number + readonly queryTimeoutMs: number + readonly maxClientRuntimeObjects: number + readonly maxClientRuntimeProperties: number + readonly maxClientSourceBytes: number + readonly maxCordisNodes: number + readonly maxDisconnectedCordisTrees: number +} + +/** Addresses and browser bootstrap of one bound Worker. */ +export interface InspectorEndpoint { + readonly httpUrl: string + readonly webSocketDebuggerUrl: string + readonly devtoolsFrontendUrl: string + readonly client: InspectorClientBootstrap +} + +/** Running Host-side Inspector owner. */ +export interface InspectorHandle { + readonly endpoint: InspectorEndpoint + readonly source: InspectorConnection + /** Stop capture and wait for the Worker to release every socket and V8 session. */ + close(): Promise +} + +/** + * Resolve and validate all deployment-varying Inspector choices. + * @param options - Partial caller configuration. + * @returns A complete immutable configuration. + */ +export function resolveInspectorOptions(options: InspectorOptions = {}): InspectorSpec { + const spec: InspectorSpec = { + host: options.host ?? '127.0.0.1', + port: natural(options.port ?? 0, 'port', true), + clientOrigins: [...(options.clientOrigins ?? [])], + captureFetch: options.captureFetch ?? true, + maxRequestBodyBytes: natural(options.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES, 'maxRequestBodyBytes'), + maxResponseBodyBytes: natural(options.maxResponseBodyBytes ?? DEFAULT_MAX_RESPONSE_BODY_BYTES, 'maxResponseBodyBytes'), + maxBodyChunkBytes: natural(options.maxBodyChunkBytes ?? DEFAULT_MAX_BODY_CHUNK_BYTES, 'maxBodyChunkBytes'), + maxJournalBytes: natural(options.maxJournalBytes ?? DEFAULT_MAX_JOURNAL_BYTES, 'maxJournalBytes'), + maxRetainedRequests: natural(options.maxRetainedRequests ?? DEFAULT_MAX_RETAINED_REQUESTS, 'maxRetainedRequests'), + maxSourceFrameBytes: natural(options.maxSourceFrameBytes ?? DEFAULT_MAX_SOURCE_FRAME_BYTES, 'maxSourceFrameBytes'), + maxSourceRecordsPerFrame: natural(options.maxSourceRecordsPerFrame ?? DEFAULT_MAX_SOURCE_RECORDS_PER_FRAME, 'maxSourceRecordsPerFrame'), + maxQueuedRecords: natural(options.maxQueuedRecords ?? DEFAULT_MAX_QUEUED_RECORDS, 'maxQueuedRecords'), + maxQueuedBytes: natural(options.maxQueuedBytes ?? DEFAULT_MAX_QUEUED_BYTES, 'maxQueuedBytes'), + startupTimeoutMs: natural(options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS, 'startupTimeoutMs'), + stopTimeoutMs: natural(options.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, 'stopTimeoutMs'), + clientReconnectBaseMs: natural(options.clientReconnectBaseMs ?? DEFAULT_CLIENT_RECONNECT_BASE_MS, 'clientReconnectBaseMs'), + clientReconnectMaxMs: natural(options.clientReconnectMaxMs ?? DEFAULT_CLIENT_RECONNECT_MAX_MS, 'clientReconnectMaxMs'), + clientRuntimeTimeoutMs: natural(options.clientRuntimeTimeoutMs ?? DEFAULT_CLIENT_RUNTIME_TIMEOUT_MS, 'clientRuntimeTimeoutMs'), + queryTimeoutMs: natural(options.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS, 'queryTimeoutMs'), + maxClientRuntimeObjects: natural(options.maxClientRuntimeObjects ?? DEFAULT_MAX_CLIENT_RUNTIME_OBJECTS, 'maxClientRuntimeObjects'), + maxClientRuntimeProperties: natural(options.maxClientRuntimeProperties ?? DEFAULT_MAX_CLIENT_RUNTIME_PROPERTIES, 'maxClientRuntimeProperties'), + maxClientSourceBytes: natural(options.maxClientSourceBytes ?? DEFAULT_MAX_CLIENT_SOURCE_BYTES, 'maxClientSourceBytes'), + maxCordisNodes: natural(options.maxCordisNodes ?? DEFAULT_MAX_CORDIS_NODES, 'maxCordisNodes'), + maxDisconnectedCordisTrees: natural( + options.maxDisconnectedCordisTrees ?? DEFAULT_MAX_DISCONNECTED_CORDIS_TREES, + 'maxDisconnectedCordisTrees', + true, + ), + } + if (spec.port > 65_535) throw new Error('inspector: port must not exceed 65535') + const largestEncodedChunk = Math.ceil(spec.maxBodyChunkBytes / 3) * 4 + 4_096 + if (largestEncodedChunk > spec.maxSourceFrameBytes) { + throw new Error('inspector: maxSourceFrameBytes cannot carry one base64 body chunk') + } + if (spec.clientReconnectMaxMs < spec.clientReconnectBaseMs) { + throw new Error('inspector: clientReconnectMaxMs must be at least clientReconnectBaseMs') + } + for (const origin of spec.clientOrigins) { + if (new URL(origin).origin !== origin) throw new Error(`inspector: client origin must be canonical: ${origin}`) + } + return spec +} + +/** + * Start the Worker, create the Host source, and install full fetch capture by default. + * @param options - Partial caller configuration. + * @returns The ready endpoint and its quiescent shutdown handle. + */ +export async function startInspector(options: InspectorOptions = {}): Promise { + const spec = resolveInspectorOptions(options) + const channel = new MessageChannel() + const clientProtocol = `dsh-inspector-v${String(INSPECTOR_PROTOCOL_VERSION)}-${randomBytes(32).toString('base64url')}` + const config: InspectorWorkerConfig = { + host: spec.host, + startPort: spec.port, + targetId: randomUUID(), + clientToken: clientProtocol, + clientOrigins: spec.clientOrigins, + maxSourceFrameBytes: spec.maxSourceFrameBytes, + maxSourceRecordsPerFrame: spec.maxSourceRecordsPerFrame, + maxRetainedRequests: spec.maxRetainedRequests, + maxJournalBytes: spec.maxJournalBytes, + clientRuntimeTimeoutMs: spec.clientRuntimeTimeoutMs, + maxClientSourceBytes: spec.maxClientSourceBytes, + maxCordisNodes: spec.maxCordisNodes, + maxDisconnectedCordisTrees: spec.maxDisconnectedCordisTrees, + } + const boot: InspectorWorkerBoot = { config, hostSourcePort: channel.port2 } + const worker = spawnWorker(boot) + const lifecycle = new InspectorWorkerLifecycle(worker) + let source: HostInspectorSource + try { + source = new HostInspectorSource(channel.port1, { + label: 'Host', + topics: ['*', ...NETWORK_TOPICS], + maxQueuedRecords: spec.maxQueuedRecords, + maxQueuedBytes: spec.maxQueuedBytes, + maxRecordsPerFrame: spec.maxSourceRecordsPerFrame, + maxFrameBytes: spec.maxSourceFrameBytes, + queryTimeoutMs: spec.queryTimeoutMs, + }) + } catch (error) { + channel.port1.close() + await lifecycle.terminate() + throw error + } + + const ready = await lifecycle.waitForReady(spec.startupTimeoutMs).catch(async (error: unknown) => { + source.close() + await lifecycle.terminate() + throw error + }) + const authority = `${ready.host}:${String(ready.port)}` + const endpoint: InspectorEndpoint = { + httpUrl: `http://${authority}/`, + webSocketDebuggerUrl: `ws://${authority}/devtools/page/${ready.targetId}`, + devtoolsFrontendUrl: `devtools://devtools/bundled/devtools_app.html?ws=${authority}/devtools/page/${ready.targetId}&panel=elements&noJavaScriptCompletion=true`, + client: { + endpoint: `ws://${authority}/ingest`, + protocol: clientProtocol, + maxQueuedRecords: spec.maxQueuedRecords, + maxQueuedBytes: spec.maxQueuedBytes, + maxRecordsPerFrame: spec.maxSourceRecordsPerFrame, + maxFrameBytes: spec.maxSourceFrameBytes, + reconnectBaseMs: spec.clientReconnectBaseMs, + reconnectMaxMs: spec.clientReconnectMaxMs, + queryTimeoutMs: spec.queryTimeoutMs, + maxRuntimeObjectsPerSession: spec.maxClientRuntimeObjects, + maxRuntimePropertiesPerResult: spec.maxClientRuntimeProperties, + maxClientSourceBytes: spec.maxClientSourceBytes, + maxCordisNodes: spec.maxCordisNodes, + }, + } + let fetchObserver: FetchObserver | undefined + try { + fetchObserver = spec.captureFetch + ? installFetchObserver(source, { + maxRequestBodyBytes: spec.maxRequestBodyBytes, + maxResponseBodyBytes: spec.maxResponseBodyBytes, + maxChunkBytes: spec.maxBodyChunkBytes, + }) + : undefined + } catch (error) { + source.close() + await lifecycle.terminate() + throw error + } + + lifecycle.markRunning((error) => { + try { + source.close() + } catch (closeError) { + console.error('dsh inspector: Host source cleanup after Worker failure failed', closeError) + } + void fetchObserver?.stop().catch((stopError: unknown) => { + console.error('dsh inspector: fetch cleanup after Worker failure failed', stopError) + }) + console.error('dsh inspector: Worker stopped unexpectedly', error) + }) + + let closing: Promise | undefined + return { + endpoint, + source, + close(): Promise { + closing ??= closeInspector(lifecycle, source, fetchObserver, spec.stopTimeoutMs) + return closing + }, + } +} + +function spawnWorker(boot: InspectorWorkerBoot): Worker { + const options: WorkerOptions = { + workerData: boot, + transferList: [boot.hostSourcePort], + execArgv: [], + } + if (!import.meta.url.endsWith('.ts')) { + return new Worker(new URL('./worker.js', import.meta.url), options) + } + const workerEntry = new URL('../../worker/entry.ts', import.meta.url) + const tsxEsmApiEntry = import.meta.resolve('tsx/esm/api') + const bootstrap = [ + `import { register } from ${JSON.stringify(tsxEsmApiEntry)}`, + 'register()', + `await import(${JSON.stringify(workerEntry.href)})`, + ].join('\n') + return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { + ...options, + env: sourceWorkerEnv(), + }) +} + +function sourceWorkerEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + if (process.platform === 'win32') { + env.TMP = tmpdir() + env.TEMP = tmpdir() + } + if (process.env.TSX_TSCONFIG_PATH !== undefined) env.TSX_TSCONFIG_PATH = process.env.TSX_TSCONFIG_PATH + return env +} + +async function closeInspector( + lifecycle: InspectorWorkerLifecycle, + source: HostInspectorSource, + fetchObserver: FetchObserver | undefined, + timeoutMs: number, +): Promise { + const failures: unknown[] = [] + try { + await fetchObserver?.stop() + } catch (error) { + failures.push(error) + } + try { + source.close() + } catch (error) { + failures.push(error) + } + try { + await lifecycle.stop(timeoutMs) + } catch (error) { + failures.push(error) + } + if (failures.length > 0) throw new AggregateError(failures, 'inspector: shutdown failed') +} + +function natural(value: number, name: string, zero = false): number { + if (!Number.isSafeInteger(value) || value < (zero ? 0 : 1)) { + throw new Error(`inspector: ${name} must be ${zero ? 'a non-negative' : 'a positive'} safe integer`) + } + return value +} diff --git a/packages/experimental/inspector/src/host/bridge/dispatcher.ts b/packages/experimental/inspector/src/host/bridge/dispatcher.ts new file mode 100644 index 0000000000..441dcb1512 --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/dispatcher.ts @@ -0,0 +1,48 @@ +/** Dispatch of validated Worker frames accepted by the Host MessagePort. */ + +import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' +import { rejectConsoleBridgeCommand } from '../cdp/console.ts' +import { rejectRuntimeBridgeCommand } from '../cdp/runtime.ts' +import { rejectSourcesBridgeCommand } from '../cdp/sources.ts' + +/** Operations invoked for source-lifecycle frames addressed to the Host. */ +export interface HostBridgeFrameHandlers { + accepted(frame: SourceAcceptedFrame): void + resnapshot(frame: SourceResnapshotFrame): void + rejected(frame: SourceRejectedFrame): void +} + +/** + * Dispatch one validated Worker frame and reject Client-only commands on the Host carrier. + * @param frame - Decoded Worker-to-source frame. + * @param handlers - Host source-lifecycle operations. + */ +export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBridgeFrameHandlers): void { + switch (frame.t) { + case 'source/accepted': + handlers.accepted(frame) + return + case 'source/resnapshot': + handlers.resnapshot(frame) + return + case 'source/rejected': + handlers.rejected(frame) + return + case 'client-runtime/request': + return rejectRuntimeBridgeCommand(frame.command) + case 'client-console/enable': + case 'client-console/disable': + return rejectConsoleBridgeCommand(frame.t) + case 'client-sources/request': + return rejectSourcesBridgeCommand() + case 'client-runtime/session-closed': + case 'client-sources/session-closed': + return + default: + return assertNever(frame) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Worker source frame: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/host/bridge/lifecycle.ts b/packages/experimental/inspector/src/host/bridge/lifecycle.ts new file mode 100644 index 0000000000..7ff5bd4328 --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/lifecycle.ts @@ -0,0 +1,125 @@ +/** Failure containment and shutdown coordination for the Inspector Worker. */ + +import type { Worker } from 'node:worker_threads' +import type { InspectorHostControl, InspectorWorkerControl } from '../../shared/bridge/messages/control.ts' +import { parseInspectorWorkerControl } from '../../shared/bridge/control-codec.ts' + +/** Tracks Worker termination without removing the listener that contains runtime errors. */ +export class InspectorWorkerLifecycle { + private readonly exitResolution = Promise.withResolvers() + private readonly failureResolution = Promise.withResolvers() + private failure: Error | undefined + private running = false + private expectedExit = false + private notified = false + private onUnexpectedExit: ((error: Error) => void) | undefined + private exitCodeValue: number | undefined + + /** Worker exit code once its `exit` event has fired. */ + get exitCode(): number | undefined { + return this.exitCodeValue + } + + constructor(private readonly worker: Worker) { + worker.on('error', (error) => { + this.failure ??= error + this.failureResolution.resolve(error) + this.notifyUnexpectedExit() + }) + worker.once('exit', (code) => { + this.exitCodeValue = code + this.exitResolution.resolve(code) + this.notifyUnexpectedExit() + }) + } + + /** + * Wait for the validated ready frame while also observing startup failure and exit. + * @param timeoutMs - Readiness deadline in milliseconds. + * @returns The Worker's bound endpoint fields. + */ + async waitForReady(timeoutMs: number): Promise> { + let timer: NodeJS.Timeout | undefined + let onMessage: ((value: unknown) => void) | undefined + const message = new Promise>((resolve, reject) => { + onMessage = (value: unknown): void => { + let control: InspectorWorkerControl + try { + control = parseInspectorWorkerControl(value) + } catch (error) { + reject(error instanceof Error ? error : new Error(String(error))) + return + } + if (control.type === 'ready') resolve(control) + else if (control.type === 'failure') reject(new Error(`inspector Worker failed: ${control.message}`)) + } + timer = setTimeout(() => { + reject(new Error(`inspector Worker did not become ready within ${String(timeoutMs)}ms`)) + }, timeoutMs) + this.worker.on('message', onMessage) + }) + try { + return await Promise.race([ + message, + this.failureResolution.promise.then((error) => { throw error }), + this.exitResolution.promise.then((code) => { + throw new Error(`inspector Worker exited before readiness (code ${String(code)})`) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + if (onMessage !== undefined) this.worker.off('message', onMessage) + } + } + + /** + * Begin reporting an unexpected runtime exit through one contained callback. + * @param listener - Failure observer that must not throw. + */ + markRunning(listener: (error: Error) => void): void { + this.running = true + this.onUnexpectedExit = listener + this.notifyUnexpectedExit() + } + + /** Mark subsequent Worker termination as owner-requested. */ + expectExit(): void { + this.expectedExit = true + } + + /** Terminate the Worker during failed initialization. */ + async terminate(): Promise { + this.expectExit() + if (this.exitCodeValue === undefined) await this.worker.terminate() + } + + /** + * Request graceful shutdown and terminate after the deadline. + * @param timeoutMs - Grace period before forced termination. + */ + async stop(timeoutMs: number): Promise { + this.expectExit() + if (this.exitCodeValue !== undefined) return + this.worker.postMessage({ type: 'shutdown' } satisfies InspectorHostControl) + let timer: NodeJS.Timeout | undefined + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => { resolve('timeout') }, timeoutMs) + }) + const outcome = await Promise.race([ + this.exitResolution.promise.then(() => 'exited' as const), + timeout, + ]) + if (timer !== undefined) clearTimeout(timer) + if (outcome === 'exited') return + await this.worker.terminate() + throw new Error(`inspector Worker did not stop within ${String(timeoutMs)}ms and was terminated`) + } + + private notifyUnexpectedExit(): void { + if (!this.running || this.expectedExit || this.notified || this.exitCodeValue === undefined) return + this.notified = true + this.onUnexpectedExit?.(this.failure ?? new Error( + `inspector Worker exited unexpectedly with code ${String(this.exitCodeValue)}`, + )) + } +} diff --git a/packages/experimental/inspector/src/host/bridge/publisher.ts b/packages/experimental/inspector/src/host/bridge/publisher.ts new file mode 100644 index 0000000000..89564261da --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/publisher.ts @@ -0,0 +1,64 @@ +/** Buffered Host observation publication over a dedicated Worker MessagePort. */ + +import type { MessagePort } from 'node:worker_threads' +import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../../shared/bridge/buffer.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' + +/** Non-blocking Host publisher with microtask-coalesced MessagePort writes. */ +export class HostBridgePublisher implements InspectorStatePublisher { + private readonly records: InspectorSourceBuffer + private flushScheduled = false + private closed = false + + constructor( + private readonly port: MessagePort, + private readonly source: InspectorSourceDescriptor, + options: InspectorSourceBufferOptions, + ) { + this.records = new InspectorSourceBuffer(options) + } + + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.records.publish(topic, payload, monotonicMs) + this.scheduleFlush() + } + + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Host source is closed') + this.records.setState(topic, payload, monotonicMs) + this.scheduleFlush() + } + + /** Send the retained state as a complete source replacement. */ + replace(): void { + this.port.postMessage(this.records.replacement(this.source.sourceId, this.source.generation)) + } + + /** Flush every currently queued observation batch. */ + flush(): void { + let frame = this.records.takeBatch(this.source.sourceId, this.source.generation) + while (frame !== undefined) { + this.port.postMessage(frame) + frame = this.records.takeBatch(this.source.sourceId, this.source.generation) + } + } + + /** Flush pending observations and reject later publication. */ + close(): void { + if (this.closed) return + this.flush() + this.closed = true + } + + private scheduleFlush(): void { + if (!this.records.hasPending || this.flushScheduled) return + this.flushScheduled = true + queueMicrotask(() => { + this.flushScheduled = false + this.flush() + }) + } +} diff --git a/packages/experimental/inspector/src/host/bridge/rpc.ts b/packages/experimental/inspector/src/host/bridge/rpc.ts new file mode 100644 index 0000000000..cf3429068b --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/rpc.ts @@ -0,0 +1,59 @@ +/** Host-side non-CDP query bridge over the Worker MessagePort. */ + +import type { MessagePort } from 'node:worker_threads' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts' + +/** Owns query correlation for one Host source generation. */ +export class HostBridgeRpc { + private readonly connection: InspectorQueryConnection + + constructor(private readonly port: MessagePort, options: InspectorQueryConnectionOptions) { + this.connection = new InspectorQueryConnection(options) + } + + /** + * Connect query writes after the Worker accepts the Host source. + * @param source - Accepted Host source descriptor. + */ + connect(source: InspectorSourceDescriptor): void { + this.connection.connect(source.sourceId, source.generation, { + send: (frame) => { this.port.postMessage(frame) }, + }) + } + + /** + * Consume a potential query response. + * @param value - Decoded Worker message. + * @returns Whether the message belonged to this RPC protocol. + */ + receive(value: unknown): boolean { + return this.connection.receive(value) + } + + /** + * Execute one non-CDP query through the active Host generation. + * @param query - Typed query operation. + * @returns Its correlated typed result. + */ + request(query: Query): Promise> { + return this.connection.request(query) + } + + /** + * Reject pending requests while retaining the reusable Host bridge. + * @param reason - Failure reported to pending callers. + */ + disconnect(reason: string): void { + this.connection.disconnect(reason) + } + + /** + * Permanently reject all current and future requests. + * @param reason - Failure reported to pending callers. + */ + close(reason: string): void { + this.connection.close(reason) + } +} diff --git a/packages/experimental/inspector/src/host/bridge/transport.ts b/packages/experimental/inspector/src/host/bridge/transport.ts new file mode 100644 index 0000000000..b2eac495d5 --- /dev/null +++ b/packages/experimental/inspector/src/host/bridge/transport.ts @@ -0,0 +1,106 @@ +/** Host-realm observation publisher over a dedicated MessagePort. */ + +import type { MessagePort } from 'node:worker_threads' +import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { + INSPECTOR_PROTOCOL_VERSION, + parseWorkerSourceFrame, + type SourceCloseFrame, + type SourceOpenFrame, + type WorkerToSourceFrame, +} from '../../shared/bridge/messages/observation.ts' +import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { createHostRealmSource } from '../inspection/realm.ts' +import { HostBridgePublisher } from './publisher.ts' +import { HostBridgeRpc } from './rpc.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import { dispatchBridgeFrame } from './dispatcher.ts' + +/** Buffer limits for one source publisher. */ +export interface HostSourceOptions { + readonly label: string + readonly topics: readonly string[] + readonly maxQueuedRecords: number + readonly maxQueuedBytes: number + readonly maxRecordsPerFrame: number + readonly maxFrameBytes: number + readonly queryTimeoutMs: number +} + +/** Non-blocking Host source; queue overflow is represented by `droppedBefore` on the next batch. */ +export class HostInspectorSource implements InspectorConnection { + private readonly source + private readonly publisher: HostBridgePublisher + private closed = false + private readonly queries: HostBridgeRpc + + constructor(private readonly port: MessagePort, options: HostSourceOptions) { + this.source = createHostRealmSource(options.label) + this.publisher = new HostBridgePublisher(port, this.source, options) + this.queries = new HostBridgeRpc(port, { + timeoutMs: options.queryTimeoutMs, + maxFrameBytes: options.maxFrameBytes, + }) + port.on('message', (value: unknown) => { + try { + if (this.queries.receive(value)) return + this.receive(parseWorkerSourceFrame(value)) + } catch { + this.close() + } + }) + port.on('close', () => { this.queries.disconnect('Inspector Host source disconnected') }) + port.start() + const open: SourceOpenFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/open', + source: this.source, + topics: [...options.topics], + } + port.postMessage(open) + this.publisher.replace() + } + + /** Publish one observation without waiting on Worker processing. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) return + this.publisher.publish(topic, payload, monotonicMs) + } + + /** Retain and publish one state value for future `source/replace` frames. */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + if (this.closed) throw new Error('inspector: Host source is closed') + this.publisher.setState(topic, payload, monotonicMs) + } + + /** Execute one non-CDP query through the accepted Host source generation. */ + request(query: Query): Promise> { + return this.queries.request(query) + } + + /** Flush pending observations and close the source port. */ + close(): void { + if (this.closed) return + this.publisher.close() + this.closed = true + this.queries.close('Inspector Host source closed') + const frame: SourceCloseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/close', + sourceId: this.source.sourceId, + generation: this.source.generation, + } + this.port.postMessage(frame) + this.port.close() + } + + private receive(frame: WorkerToSourceFrame): void { + if (frame.t !== 'source/rejected' + && (frame.sourceId !== this.source.sourceId || frame.generation !== this.source.generation)) return + dispatchBridgeFrame(frame, { + accepted: () => { this.queries.connect(this.source) }, + resnapshot: () => { this.publisher.replace() }, + rejected: (rejected) => { this.queries.disconnect(`Inspector Host source rejected: ${rejected.message}`) }, + }) + } +} diff --git a/packages/experimental/inspector/src/host/cdp/console.ts b/packages/experimental/inspector/src/host/cdp/console.ts new file mode 100644 index 0000000000..343913d367 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/console.ts @@ -0,0 +1,20 @@ +/** Host Console is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host Console transport ownership. + * @returns No Host-main-thread Console bridge capability. + */ +export function consoleBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} + +/** + * Reject a Client Console control frame that was routed to the Host source. + * @param operation - Misrouted Console frame type. + * @returns This function never returns. + */ +export function rejectConsoleBridgeCommand(operation: string): never { + throw new Error(`inspector protocol: ${operation} cannot use the Host source bridge`) +} diff --git a/packages/experimental/inspector/src/host/cdp/debugger.ts b/packages/experimental/inspector/src/host/cdp/debugger.ts new file mode 100644 index 0000000000..e464ca4371 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/debugger.ts @@ -0,0 +1,11 @@ +/** Host debugging is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host debugger transport ownership. + * @returns No Host-main-thread Debugger bridge capability. + */ +export function debuggerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/host/cdp/errors.ts b/packages/experimental/inspector/src/host/cdp/errors.ts new file mode 100644 index 0000000000..011354dca3 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/errors.ts @@ -0,0 +1,10 @@ +/** Explicit failure for Client-style CDP bridge commands misrouted to the Host. */ + +import { HOST_CDP_BRIDGE_REASON } from './stack.ts' + +/** Host Runtime uses the Worker-side Node inspector session instead of source RPC. */ +export class HostCdpBridgeUnavailableError extends Error { + constructor(operation: string) { + super(`inspector protocol: ${operation} cannot use the Host source bridge; ${HOST_CDP_BRIDGE_REASON}`) + } +} diff --git a/packages/experimental/inspector/src/host/cdp/heap-profiler.ts b/packages/experimental/inspector/src/host/cdp/heap-profiler.ts new file mode 100644 index 0000000000..bd2c1ed8b6 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/heap-profiler.ts @@ -0,0 +1,11 @@ +/** Host heap profiling is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host heap profiler transport ownership. + * @returns No Host-main-thread HeapProfiler bridge capability. + */ +export function heapProfilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/host/cdp/index.ts b/packages/experimental/inspector/src/host/cdp/index.ts new file mode 100644 index 0000000000..de7fb195c6 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/index.ts @@ -0,0 +1,26 @@ +/** Source-side CDP capability declarations for the Host realm. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' +import { consoleBridgeCapability } from './console.ts' +import { debuggerBridgeCapability } from './debugger.ts' +import { heapProfilerBridgeCapability } from './heap-profiler.ts' +import { profilerBridgeCapability } from './profiler.ts' +import { runtimeBridgeCapability } from './runtime.ts' +import { sourcesBridgeCapability } from './sources.ts' + +/** + * Collect Host source-bridge capabilities. + * @param origin - Unused Host origin supplied for parity with the Client adapter. + * @param hasSources - Unused source availability supplied for parity with the Client adapter. + * @returns No capabilities because the Worker attaches to Host V8 directly. + */ +export function bridgeCapabilities(origin: string, hasSources: boolean): readonly InspectorSourceCapability[] { + return [ + runtimeBridgeCapability(origin), + consoleBridgeCapability(), + sourcesBridgeCapability(hasSources), + debuggerBridgeCapability(), + profilerBridgeCapability(), + heapProfilerBridgeCapability(), + ].filter((capability): capability is InspectorSourceCapability => capability !== undefined) +} diff --git a/packages/experimental/inspector/src/host/cdp/objects.ts b/packages/experimental/inspector/src/host/cdp/objects.ts new file mode 100644 index 0000000000..dffce7b35b --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/objects.ts @@ -0,0 +1,12 @@ +/** Host RemoteObject handles never cross the Host source bridge. */ + +import { HostCdpBridgeUnavailableError } from './errors.ts' + +/** + * Reject an object operation that must use the Worker-owned native inspector session. + * @param operation - Misrouted object operation. + * @returns This function never returns. + */ +export function rejectObjectBridgeOperation(operation: string): never { + throw new HostCdpBridgeUnavailableError(operation) +} diff --git a/packages/experimental/inspector/src/host/cdp/profiler.ts b/packages/experimental/inspector/src/host/cdp/profiler.ts new file mode 100644 index 0000000000..76fc681403 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/profiler.ts @@ -0,0 +1,11 @@ +/** Host CPU profiling is served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host CPU profiler transport ownership. + * @returns No Host-main-thread Profiler bridge capability. + */ +export function profilerBridgeCapability(): InspectorSourceCapability | undefined { + return undefined +} diff --git a/packages/experimental/inspector/src/host/cdp/properties.ts b/packages/experimental/inspector/src/host/cdp/properties.ts new file mode 100644 index 0000000000..e28c8c4d6e --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/properties.ts @@ -0,0 +1,11 @@ +/** Host property enumeration never crosses the Host source bridge. */ + +import { rejectObjectBridgeOperation } from './objects.ts' + +/** + * Reject a property request that must use the Worker-owned native inspector session. + * @returns This function never returns. + */ +export function rejectPropertyBridgeOperation(): never { + return rejectObjectBridgeOperation('client-runtime/get-properties') +} diff --git a/packages/experimental/inspector/src/host/cdp/runtime.ts b/packages/experimental/inspector/src/host/cdp/runtime.ts new file mode 100644 index 0000000000..8e7484d616 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/runtime.ts @@ -0,0 +1,42 @@ +/** Host Runtime is served directly by the Worker-side Node inspector adapter. */ + +import type { ClientRuntimeCommand } from '../../shared/bridge/messages/runtime/index.ts' +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' +import { HostCdpBridgeUnavailableError } from './errors.ts' +import { rejectObjectBridgeOperation } from './objects.ts' +import { rejectPropertyBridgeOperation } from './properties.ts' + +/** + * Describe Host Runtime transport ownership. + * @param _origin - Ignored because Host Runtime does not cross the source bridge. + * @returns No Host-main-thread Runtime bridge capability. + */ +export function runtimeBridgeCapability(_origin: string): InspectorSourceCapability | undefined { + return undefined +} + +/** + * Reject a Client Runtime command that was routed to the Host source. + * @param command - Misrouted Client Runtime operation. + * @returns This function never returns. + */ +export function rejectRuntimeBridgeCommand(command: ClientRuntimeCommand): never { + switch (command.op) { + case 'get-properties': + return rejectPropertyBridgeOperation() + case 'release-object': + case 'release-object-group': + return rejectObjectBridgeOperation(`client-runtime/${command.op}`) + case 'evaluate': + case 'call-function': + case 'await-promise': + case 'global-lexical-scope-names': + throw new HostCdpBridgeUnavailableError(`client-runtime/${command.op}`) + default: + return assertNever(command) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Host Runtime bridge command: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/host/cdp/sources.ts b/packages/experimental/inspector/src/host/cdp/sources.ts new file mode 100644 index 0000000000..7b93937bad --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/sources.ts @@ -0,0 +1,20 @@ +/** Host Sources are served directly by the Worker-side Node inspector adapter. */ + +import type { InspectorSourceCapability } from '../../shared/bridge/messages/observation.ts' + +/** + * Describe Host Sources transport ownership. + * @param _available - Ignored because Host Sources do not cross the source bridge. + * @returns No Host-main-thread Sources bridge capability. + */ +export function sourcesBridgeCapability(_available: boolean): InspectorSourceCapability | undefined { + return undefined +} + +/** + * Reject a Client Sources request that was routed to the Host source. + * @returns This function never returns. + */ +export function rejectSourcesBridgeCommand(): never { + throw new Error('inspector protocol: Client Sources cannot use the Host source bridge') +} diff --git a/packages/experimental/inspector/src/host/cdp/stack.ts b/packages/experimental/inspector/src/host/cdp/stack.ts new file mode 100644 index 0000000000..c633ea0041 --- /dev/null +++ b/packages/experimental/inspector/src/host/cdp/stack.ts @@ -0,0 +1,4 @@ +/** Host stack and call-frame data remain owned by the Worker-side Node inspector session. */ + +/** Stable explanation used for Host bridge rejections. */ +export const HOST_CDP_BRIDGE_REASON = 'Host Runtime is attached directly from the Inspector Worker' diff --git a/packages/experimental/inspector/src/host/index.ts b/packages/experimental/inspector/src/host/index.ts new file mode 100644 index 0000000000..0af3db1fae --- /dev/null +++ b/packages/experimental/inspector/src/host/index.ts @@ -0,0 +1,3 @@ +/** Host entry for the experimental Inspector Cordis plugin and library API. */ + +export * from './plugin.ts' diff --git a/packages/experimental/inspector/src/host/inspection/realm.ts b/packages/experimental/inspector/src/host/inspection/realm.ts new file mode 100644 index 0000000000..ca4b7329e6 --- /dev/null +++ b/packages/experimental/inspector/src/host/inspection/realm.ts @@ -0,0 +1,22 @@ +/** Stable descriptor for the Host observation source generation. */ + +import { randomUUID } from 'node:crypto' +import { inspectorId } from '../../shared/identity.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { bridgeCapabilities } from '../cdp/index.ts' + +/** + * Create the descriptor for one Host-to-Worker MessagePort generation. + * @param label - Human-readable Host execution-context label. + * @returns The complete Host source descriptor. + */ +export function createHostRealmSource(label: string): InspectorSourceDescriptor { + return { + sourceId: inspectorId<'InspectorSourceId'>(`host-${randomUUID()}`, 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'), + kind: 'host', + label, + timeOriginMs: performance.timeOrigin, + capabilities: bridgeCapabilities('', false), + } +} diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts new file mode 100644 index 0000000000..039eb1b8ca --- /dev/null +++ b/packages/experimental/inspector/src/host/plugin.ts @@ -0,0 +1,81 @@ +/** Host Cordis plugin for the cross-realm Inspector Worker and full fetch capture. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' +import { resolveInspectorOptions, startInspector, type InspectorOptions } from './bridge/controller.ts' +import { createInspectorService } from '../shared/service.ts' +import { publishCordisTree } from './inspection/cordis.ts' + +export { resolveInspectorOptions, startInspector } from './bridge/controller.ts' +export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './bridge/controller.ts' +export type { CordisRuntimeTreeReader } from '../shared/cordis/reader.ts' +export type { + CordisRuntimeConnection, + CordisRuntimeContext, + CordisRuntimeFiber, + CordisRuntimeNode, + CordisRuntimeRealm, + CordisRuntimeSource, + CordisRuntimeTree, +} from '../shared/cordis/model.ts' +export type { InspectorClientBootstrap } from '../shared/bridge/messages/control.ts' +export type { InspectorRecordInput, InspectorSourceDescriptor, InspectorSourceKind } from '../shared/bridge/messages/observation.ts' +export type { InspectorJsonObject, InspectorJsonPrimitive, InspectorJsonValue } from '../shared/json.ts' +export type { + CordisContextTreeNode, + CordisFiberTreeNode, + CordisTreeNode, + CordisTreeSnapshot, +} from '../shared/cordis/snapshot.ts' + +/** Configuration consumed by the Host implementation after package-entry validation. */ +export interface HostPluginConfig extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +/** Start the Worker, expose `ctx.inspector`, and inject the matching Client bootstrap. */ +export async function apply(ctx: Context, config: HostPluginConfig): Promise { + await ctx.effect(async () => { + const spec = resolveInspectorOptions(config) + const handle = await startInspector(spec) + const disposers: Array<() => unknown> = [] + try { + disposers.push(publishCordisTree(ctx, handle.source, { + maxNodes: spec.maxCordisNodes, + maxBytes: spec.maxSourceFrameBytes - 4_096, + })) + disposers.push(ctx.provide('inspector', createInspectorService(handle.source))) + disposers.push(ctx.on('webserver/index-inject', (table: IndexInjection[]) => { + table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client }) + })) + console.log(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`) + } catch (error) { + await disposeInspector(handle, disposers).catch((cleanupError: unknown) => { + ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError) + }) + throw error + } + return async () => { await disposeInspector(handle, disposers) } + }, 'experimental-inspector: Host Worker') +} + +async function disposeInspector( + handle: Awaited>, + disposers: readonly (() => unknown)[], +): Promise { + const failures: unknown[] = [] + for (const dispose of [...disposers].reverse()) { + try { + await dispose() + } catch (error) { + failures.push(error) + } + } + try { + await handle.close() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) throw new AggregateError(failures, 'experimental-inspector: disposal failed') +} diff --git a/packages/experimental/inspector/src/index.ts b/packages/experimental/inspector/src/index.ts new file mode 100644 index 0000000000..9617d50a10 --- /dev/null +++ b/packages/experimental/inspector/src/index.ts @@ -0,0 +1,108 @@ +/** Repository-facing Host package entry over the mirrored implementation tree. */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { + apply as applyHost, +} from './host/plugin.ts' +import { resolveInspectorOptions, type InspectorOptions } from './host/bridge/controller.ts' +import type { CordisRuntimeTreeReader } from './shared/cordis/reader.ts' +import type { InspectorJsonValue } from './shared/json.ts' + +export { resolveInspectorOptions, startInspector } from './host/plugin.ts' +export type { InspectorEndpoint, InspectorHandle, InspectorOptions, InspectorSpec } from './host/plugin.ts' +export type { CordisRuntimeTreeReader } from './shared/cordis/reader.ts' +export type { + CordisRuntimeConnection, + CordisRuntimeContext, + CordisRuntimeFiber, + CordisRuntimeNode, + CordisRuntimeRealm, + CordisRuntimeSource, + CordisRuntimeTree, +} from './shared/cordis/model.ts' +export type { InspectorClientBootstrap } from './shared/bridge/messages/control.ts' +export type { + InspectorRecordInput, + InspectorSourceDescriptor, + InspectorSourceKind, +} from './shared/bridge/messages/observation.ts' +export type { InspectorJsonObject, InspectorJsonPrimitive, InspectorJsonValue } from './shared/json.ts' +export type { + CordisContextTreeNode, + CordisFiberTreeNode, + CordisTreeNode, + CordisTreeSnapshot, +} from './shared/cordis/snapshot.ts' + +/** Shared Host/Client service façade over the realm's source publisher. */ +export interface InspectorService { + /** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void + + /** Read-only Cordis topology queries independent of CDP sessions. */ + readonly cordis: CordisRuntimeTreeReader +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Publish Host-realm observations and query the shared Inspector state. */ + inspector: InspectorService + } +} + +/** Cordis plugin name shared with the Client face. */ +export const name = 'experimental-inspector' + +/** Host service required to inject the Client connection bootstrap into index.html. */ +export const inject = ['webServer'] + +/** Host plugin configuration. Fetch capture is enabled by default. */ +export interface Config extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +const libraryDefaults = resolveInspectorOptions() + +/** Runtime validation for {@link Config}. */ +export const Config: z = z.object({ + host: z.const('127.0.0.1').default('127.0.0.1'), + port: z.natural().max(65_535).default(9_230), + clientOrigins: z.array(z.string()).default([]), + captureFetch: z.boolean().default(true), + maxRequestBodyBytes: z.natural().min(1).default(libraryDefaults.maxRequestBodyBytes), + maxResponseBodyBytes: z.natural().min(1).default(libraryDefaults.maxResponseBodyBytes), + maxBodyChunkBytes: z.natural().min(1).default(libraryDefaults.maxBodyChunkBytes), + maxJournalBytes: z.natural().min(1).default(libraryDefaults.maxJournalBytes), + maxRetainedRequests: z.natural().min(1).default(libraryDefaults.maxRetainedRequests), + maxSourceFrameBytes: z.natural().min(1).default(libraryDefaults.maxSourceFrameBytes), + maxSourceRecordsPerFrame: z.natural().min(1).default(libraryDefaults.maxSourceRecordsPerFrame), + maxQueuedRecords: z.natural().min(1).default(libraryDefaults.maxQueuedRecords), + maxQueuedBytes: z.natural().min(1).default(libraryDefaults.maxQueuedBytes), + startupTimeoutMs: z.natural().min(1).default(libraryDefaults.startupTimeoutMs), + stopTimeoutMs: z.natural().min(1).default(libraryDefaults.stopTimeoutMs), + clientReconnectBaseMs: z.natural().min(1).default(libraryDefaults.clientReconnectBaseMs), + clientReconnectMaxMs: z.natural().min(1).default(libraryDefaults.clientReconnectMaxMs), + clientRuntimeTimeoutMs: z.natural().min(1).default(libraryDefaults.clientRuntimeTimeoutMs), + queryTimeoutMs: z.natural().min(1).default(libraryDefaults.queryTimeoutMs), + maxClientRuntimeObjects: z.natural().min(1).default(libraryDefaults.maxClientRuntimeObjects), + maxClientRuntimeProperties: z.natural().min(1).default(libraryDefaults.maxClientRuntimeProperties), + maxClientSourceBytes: z.natural().min(1).default(libraryDefaults.maxClientSourceBytes), + maxCordisNodes: z.natural().min(1).default(libraryDefaults.maxCordisNodes), + maxDisconnectedCordisTrees: z.natural().default(libraryDefaults.maxDisconnectedCordisTrees), +}) + +/** + * Apply the Host implementation from the repository-standard package entry. + * @param ctx - Host Cordis plugin context. + * @param config - Validated Inspector configuration. + */ +export async function apply(ctx: Context, config: Config): Promise { + await applyHost(ctx, config) +} diff --git a/packages/experimental/inspector/tests/client-runtime.client.spec.ts b/packages/experimental/inspector/tests/client-runtime.client.spec.ts new file mode 100644 index 0000000000..c07e0b85d2 --- /dev/null +++ b/packages/experimental/inspector/tests/client-runtime.client.spec.ts @@ -0,0 +1,247 @@ +/** Client-face Runtime behavior. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { ClientRuntimeExecutor } from '../src/client/cdp/runtime.ts' +import type { + ClientRuntimeCommand, + ClientRuntimeRequestFrame, + ClientRuntimeResult, +} from '../src/shared/bridge/messages/runtime/index.ts' +import { + inspectorId, +} from '../src/shared/bridge/ids.ts' + +const sourceId = inspectorId<'InspectorSourceId'>('client-test', 'sourceId') +const generation = inspectorId<'InspectorSourceGeneration'>('generation-test', 'generation') +const sessionId = inspectorId<'ClientRuntimeSessionId'>('session-test', 'sessionId') +const secondSessionId = inspectorId<'ClientRuntimeSessionId'>('session-second', 'sessionId') + +describe('Client Runtime executor', () => { + afterEach(() => { + Reflect.deleteProperty(globalThis, '__clientRuntimeFixture') + Reflect.deleteProperty(globalThis, '__clientRuntimeGetterCalls') + }) + + it('retains RemoteObjects, reads descriptors lazily, calls functions, and releases groups', async () => { + Reflect.set(globalThis, '__clientRuntimeGetterCalls', 0) + const fixture = { + value: 4, + get dangerous(): number { + const calls = Number(Reflect.get(globalThis, '__clientRuntimeGetterCalls')) + Reflect.set(globalThis, '__clientRuntimeGetterCalls', calls + 1) + return 99 + }, + } + Object.defineProperty(fixture, Symbol.toStringTag, { + get() { + const calls = Number(Reflect.get(globalThis, '__clientRuntimeGetterCalls')) + Reflect.set(globalThis, '__clientRuntimeGetterCalls', calls + 1) + return 'DangerousTag' + }, + }) + Reflect.set(globalThis, '__clientRuntimeFixture', fixture) + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + + const evaluated = success(await runtime.execute(frame({ + op: 'evaluate', + expression: 'globalThis.__clientRuntimeFixture', + objectGroup: 'console', + generatePreview: true, + })), 'evaluate') + const handle = evaluated.completion.result.object?.handle + if (handle === undefined) throw new Error('evaluate did not return a Client object handle') + + const properties = success(await runtime.execute(frame({ + op: 'get-properties', + handle, + ownProperties: true, + })), 'get-properties') + const valueProperty = properties.properties.find(property => property.name === 'value') + const getterProperty = properties.properties.find(property => property.name === 'dangerous') + expect(valueProperty?.value).toMatchObject({ descriptor: { type: 'number', value: 4 } }) + expect(getterProperty?.get).toMatchObject({ descriptor: { type: 'function' } }) + expect(Reflect.get(globalThis, '__clientRuntimeGetterCalls')).toBe(0) + + const called = success(await runtime.execute(frame({ + op: 'call-function', + functionDeclaration: 'function (increment) { return this.value + increment }', + receiver: handle, + arguments: [{ kind: 'value', value: 3 }], + returnByValue: true, + })), 'call-function') + expect(called.completion.result).toMatchObject({ descriptor: { type: 'number', value: 7 } }) + + success(await runtime.execute(frame({ op: 'release-object-group', objectGroup: 'console' })), 'release-object-group') + const released = await runtime.execute(frame({ op: 'get-properties', handle })) + expect(released.outcome).toEqual({ + ok: false, + error: { code: 'object-not-found', message: 'Client RemoteObject was released' }, + }) + }) + + it('keeps evaluated exceptions separate from transport failures', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const result = success(await runtime.execute(frame({ + op: 'evaluate', + expression: 'throw new TypeError("bad value")', + })), 'evaluate') + expect(result.completion.exceptionDetails).toMatchObject({ + text: 'Uncaught', + exception: { descriptor: { type: 'object', subtype: 'error' } }, + }) + expect(result.completion.result).toMatchObject({ descriptor: { type: 'object', subtype: 'error' } }) + }) + + it('preserves non-JSON primitives and reports bounded async execution failures', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const values = [ + ['NaN', { descriptor: { type: 'number', unserializableValue: 'NaN' } }], + ['-0', { descriptor: { type: 'number', unserializableValue: '-0' } }], + ['12n', { descriptor: { type: 'bigint', unserializableValue: '12n' } }], + ['null', { descriptor: { type: 'object', subtype: 'null', value: null } }], + ] as const + for (const [expression, expected] of values) { + const result = success(await runtime.execute(frame({ op: 'evaluate', expression })), 'evaluate') + expect(result.completion.result).toMatchObject(expected) + } + const fn = success(await runtime.execute(frame({ + op: 'evaluate', + expression: '(value) => value', + generatePreview: true, + })), 'evaluate') + expect(fn.completion.result).toMatchObject({ descriptor: { type: 'function' } }) + expect(fn.completion.result.descriptor.preview).toBeUndefined() + + const timedOut = await runtime.execute(frame({ + op: 'evaluate', + expression: 'new Promise(() => {})', + awaitPromise: true, + timeoutMs: 1, + })) + expect(timedOut.outcome).toMatchObject({ ok: false, error: { code: 'timeout' } }) + }) + + it('rolls back only objects allocated by the failing concurrent request', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const blocked = runtime.execute(frame({ + op: 'evaluate', + expression: 'new Promise(() => {})', + awaitPromise: true, + timeoutMs: 10, + })) + const completed = success(await runtime.execute(frame({ + op: 'evaluate', + expression: '({ retainedByConcurrentRequest: true })', + })), 'evaluate') + const handle = completed.completion.result.object?.handle + if (handle === undefined) throw new Error('concurrent evaluation did not retain an object') + + await expect(blocked).resolves.toMatchObject({ outcome: { ok: false, error: { code: 'timeout' } } }) + const properties = success(await runtime.execute(frame({ + op: 'get-properties', + handle, + ownProperties: true, + })), 'get-properties') + expect(properties.properties.find(property => property.name === 'retainedByConcurrentRequest')?.value) + .toMatchObject({ descriptor: { value: true } }) + }) + + it('rejects oversized by-value results before they enter the source transport', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 256, + }) + const response = await runtime.execute(frame({ + op: 'evaluate', + expression: '"x".repeat(1000)', + returnByValue: true, + })) + expect(response.outcome).toMatchObject({ ok: false, error: { code: 'result-too-large' } }) + }) + + it('drops every retained handle when its DevTools Runtime session closes', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const evaluated = success(await runtime.execute(frame({ + op: 'evaluate', + expression: '({ retained: true })', + })), 'evaluate') + const handle = evaluated.completion.result.object?.handle + if (handle === undefined) throw new Error('evaluate did not return a Client object handle') + + runtime.closeSession(sessionId) + const response = await runtime.execute(frame({ op: 'get-properties', handle })) + expect(response.outcome).toMatchObject({ ok: false, error: { code: 'object-not-found' } }) + }) + + it('serializes Console objects into isolated DevTools sessions', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 100, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const value = { owner: 'console' } + const first = runtime.consoleEvent(sessionId, 'log', [value], 12) + const second = runtime.consoleEvent(secondSessionId, 'log', [value], 12) + if (first?.type !== 'console-api' || second?.type !== 'console-api') { + throw new Error('Console event was unexpectedly dropped') + } + const firstHandle = first.event.arguments[0]?.object?.handle + const secondHandle = second.event.arguments[0]?.object?.handle + if (firstHandle === undefined || secondHandle === undefined) throw new Error('Console object was not retained') + + runtime.releaseObjectGroup(sessionId, 'console') + expect((await runtime.execute(frame({ op: 'get-properties', handle: firstHandle }))).outcome) + .toMatchObject({ ok: false, error: { code: 'object-not-found' } }) + const properties = success(await runtime.execute( + frame({ op: 'get-properties', handle: secondHandle }, secondSessionId), + ), 'get-properties').properties + expect(properties.find(property => property.name === 'owner')?.value?.descriptor.value).toBe('console') + }) +}) + +let nextRequestId = 0 + +function frame( + command: ClientRuntimeCommand, + owner: ClientRuntimeRequestFrame['sessionId'] = sessionId, +): ClientRuntimeRequestFrame { + return { + v: 0, + t: 'client-runtime/request', + sourceId, + generation, + sessionId: owner, + requestId: inspectorId<'ClientRuntimeRequestId'>(`request-${String(++nextRequestId)}`, 'requestId'), + command, + } +} + +function success( + response: Awaited>, + operation: Operation, +): Extract { + if (!response.outcome.ok) throw new Error(response.outcome.error.message) + if (response.outcome.result.op !== operation) throw new Error('unexpected Client Runtime result') + return response.outcome.result as Extract +} diff --git a/packages/experimental/inspector/tests/client-sources.client.spec.ts b/packages/experimental/inspector/tests/client-sources.client.spec.ts new file mode 100644 index 0000000000..06d790214d --- /dev/null +++ b/packages/experimental/inspector/tests/client-sources.client.spec.ts @@ -0,0 +1,84 @@ +/** Client-face source catalog behavior. */ + +import { describe, expect, it } from 'vitest' +import { ClientSourceCatalog } from '../src/client/cdp/sources.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' + +const scriptKey = inspectorId<'RuntimeScriptKey'>('bundle', 'scriptKey') + +describe('Client source catalog', () => { + it('describes scripts and transfers UTF-8 source and maps in bounded chunks', async () => { + const source = 'const greeting = "你好"\nconsole.log(greeting)\n' + const sourceMap = JSON.stringify({ version: 3, sources: ['client.ts'], mappings: 'AAAA' }) + const catalog = new ClientSourceCatalog([{ + scriptKey, + url: 'http://client.test/plugins/inspector/client.js?rev=abc', + hash: 'abc', + sourceMapUrl: 'http://client.test/plugins/inspector/client.js.map?rev=abc', + isModule: false, + loadSource: async () => source, + loadSourceMap: async () => sourceMap, + }]) + + await expect(catalog.execute({ op: 'list-scripts' }, 1_024)).resolves.toEqual({ + op: 'list-scripts', + scripts: [{ + scriptKey, + url: 'http://client.test/plugins/inspector/client.js?rev=abc', + hash: 'abc', + buildId: '', + sourceMapUrl: 'http://client.test/plugins/inspector/client.js.map?rev=abc', + startLine: 0, + startColumn: 0, + endLine: 2, + endColumn: 0, + isModule: false, + length: source.length, + }], + }) + + const bytes: Uint8Array[] = [] + let offset = 0 + while (true) { + const result = await catalog.execute({ + op: 'get-content-chunk', + scriptKey, + content: 'source', + offset, + maxBytes: 7, + }, 1_024) + if (result.op !== 'get-content-chunk' || !result.available) throw new Error('missing source chunk') + bytes.push(Uint8Array.from(atob(result.data), character => character.charCodeAt(0))) + offset = result.nextOffset + if (result.eof) break + } + const combined = new Uint8Array(bytes.reduce((total, chunk) => total + chunk.byteLength, 0)) + let cursor = 0 + for (const chunk of bytes) { + combined.set(chunk, cursor) + cursor += chunk.byteLength + } + expect(new TextDecoder().decode(combined)).toBe(source) + + const map = await catalog.execute({ + op: 'get-content-chunk', + scriptKey, + content: 'source-map', + offset: 0, + maxBytes: 1_024, + }, 1_024) + if (map.op !== 'get-content-chunk' || !map.available) throw new Error('missing source map') + expect(new TextDecoder().decode(Uint8Array.from(atob(map.data), character => character.charCodeAt(0)))) + .toBe(sourceMap) + }) + + it('rejects assets above the configured aggregate limit', async () => { + const catalog = new ClientSourceCatalog([{ + scriptKey, + url: 'http://client.test/client.js', + hash: 'abc', + loadSource: async () => 'x'.repeat(101), + }]) + await expect(catalog.execute({ op: 'list-scripts' }, 100)).rejects.toMatchObject({ code: 'result-too-large' }) + }) +}) diff --git a/packages/experimental/inspector/tests/client-stack.client.spec.ts b/packages/experimental/inspector/tests/client-stack.client.spec.ts new file mode 100644 index 0000000000..ed3de67867 --- /dev/null +++ b/packages/experimental/inspector/tests/client-stack.client.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { parseClientStack } from '../src/client/cdp/stack.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' + +describe('Client stack projection', () => { + it('normalizes browser line numbers and associates known source URLs', () => { + const key = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey') + const stack = parseClientStack([ + 'Error', + ' at capture (http://client.test/client.js?rev=1:10:4)', + ' at http://client.test/app.js:20:8', + ].join('\n'), url => url.includes('/client.js') ? key : undefined, 0) + expect(stack).toEqual({ + callFrames: [ + { + functionName: 'capture', + scriptKey: key, + url: 'http://client.test/client.js?rev=1', + lineNumber: 9, + columnNumber: 3, + }, + { + functionName: '', + url: 'http://client.test/app.js', + lineNumber: 19, + columnNumber: 7, + }, + ], + }) + }) +}) diff --git a/packages/experimental/inspector/tests/loader-composition.host.spec.ts b/packages/experimental/inspector/tests/loader-composition.host.spec.ts new file mode 100644 index 0000000000..35fa9103b5 --- /dev/null +++ b/packages/experimental/inspector/tests/loader-composition.host.spec.ts @@ -0,0 +1,82 @@ +/** Host Loader composition behavior. */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as Inspector from '../src/index.ts' + +let root: string | undefined +let context: Context | undefined + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) + +describe('experimental Inspector through a real Loader composition', () => { + it('loads the named-export Host face from cordis.yml and releases its endpoint', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-inspector-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-host-webserver'", + ' config:', + " host: '127.0.0.1'", + ' port: 0', + "- name: '@deepseek-ai/dsh-experimental-inspector'", + ' config:', + ' port: 0', + ' captureFetch: false', + '', + ].join('\n')) + + context = new Context() + context.baseUrl = pathToFileURL(root).href + '/' + await context.plugin(Loader) + expect('default' in Inspector).toBe(false) + const plugin = context.loader.unwrapExports(Inspector) as Record + expect(plugin).toMatchObject({ + name: Inspector.name, + inject: Inspector.inject, + Config: Inspector.Config, + apply: Inspector.apply, + }) + context.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-host-webserver', WebServer], + ['@deepseek-ai/dsh-experimental-inspector', Inspector], + ]) + context.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await context.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await context.loader.await() + + expect([...context.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled)) + .toEqual([]) + await vi.waitFor(async () => { + expect((await context!.inspector.cordis.getTree()).host?.source.kind).toBe('host') + }) + + const inspectorEntry = [...context.loader.entries()] + .find(entry => entry.options.name === '@deepseek-ai/dsh-experimental-inspector') + expect(inspectorEntry?.fiber).toBeDefined() + await inspectorEntry!.fiber!.dispose() + expect(context.get('inspector')).toBeUndefined() + }) +}) diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts new file mode 100644 index 0000000000..ea3cbd3e31 --- /dev/null +++ b/packages/experimental/inspector/tests/plugin.client.spec.ts @@ -0,0 +1,302 @@ +// @vitest-environment jsdom + +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply } from '../src/client/index.ts' +import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts' + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static readonly sockets: FakeWebSocket[] = [] + + readonly sent: string[] = [] + readonly url: string + readonly protocol: string + readyState = FakeWebSocket.CONNECTING + bufferedAmount = 0 + + constructor(url: string | URL, protocols?: string | string[]) { + super() + this.url = String(url) + this.protocol = typeof protocols === 'string' ? protocols : protocols?.[0] ?? '' + FakeWebSocket.sockets.push(this) + } + + send(data: string): void { + this.sent.push(data) + } + + close(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + open(): void { + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + receive(value: unknown): void { + this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify(value) })) + } +} + +const bootstrap: InspectorClientBootstrap = { + endpoint: 'ws://127.0.0.1:9230/ingest', + protocol: 'dsh-inspector-v0-token', + maxQueuedRecords: 16, + maxQueuedBytes: 16_384, + maxRecordsPerFrame: 8, + maxFrameBytes: 32_768, + reconnectBaseMs: 10, + reconnectMaxMs: 20, + queryTimeoutMs: 100, + maxRuntimeObjectsPerSession: 100, + maxRuntimePropertiesPerResult: 100, + maxClientSourceBytes: 1_048_576, + maxCordisNodes: 100, +} + +describe('experimental Inspector Client plugin', () => { + const nativeWebSocket = globalThis.WebSocket + const nativeFetch = globalThis.fetch + + afterEach(() => { + FakeWebSocket.sockets.length = 0 + globalThis.WebSocket = nativeWebSocket + globalThis.fetch = nativeFetch + delete globalThis.__DSH_INSPECTOR__ + Reflect.deleteProperty(globalThis, '__DSH_BOOT__') + }) + + it('provides ctx.inspector and sends observations after the Worker accepts the source', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe(bootstrap.endpoint) + expect(socket.protocol).toBe(bootstrap.protocol) + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + expect(JSON.parse(socket.sent[1]!) as unknown).toMatchObject({ + t: 'source/replace', + records: [{ topic: 'cordis/tree', payload: { schemaVersion: 0, truncated: false } }], + }) + + const treePromise = ctx.inspector.cordis.getTree() + const treeRequest = socket.sent.map(value => JSON.parse(value) as { t: string; requestId?: string }) + .find(frame => frame.t === 'query/request') + expect(treeRequest?.requestId).toBeTypeOf('string') + socket.receive({ + v: 0, + t: 'query/response', + sourceId: open.source.sourceId, + generation: open.source.generation, + requestId: treeRequest!.requestId, + outcome: { + ok: true, + result: { op: 'cordis-tree/get', tree: { schemaVersion: 0, host: null, clients: [] } }, + }, + }) + await expect(treePromise).resolves.toEqual({ schemaVersion: 0, host: null, clients: [] }) + + ctx.inspector.publish('client/probe', { ready: true }, 7) + const append = socket.sent.map(value => JSON.parse(value) as { + t: string + records: Array<{ topic: string; monotonicMs: number; payload: unknown }> + }).find(frame => frame.t === 'source/append' + && frame.records.some(record => record.topic === 'client/probe')) + expect(append).toMatchObject({ + t: 'source/append', + records: [{ topic: 'client/probe', monotonicMs: 7, payload: { ready: true } }], + }) + + document.title = 'Inspector Client Realm' + socket.receive({ + v: 0, + t: 'client-runtime/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'devtools-1', + requestId: 'runtime-1', + command: { op: 'evaluate', expression: 'document.title', returnByValue: true }, + }) + await vi.waitFor(() => { + const response = socket.sent.map(value => JSON.parse(value) as { requestId?: string }) + .find(frame => frame.requestId === 'runtime-1') + expect(response).toMatchObject({ + t: 'client-runtime/response', + sessionId: 'devtools-1', + requestId: 'runtime-1', + outcome: { + ok: true, + result: { op: 'evaluate', completion: { result: { descriptor: { value: 'Inspector Client Realm' } } } }, + }, + }) + }) + + await fiber.dispose() + expect(JSON.parse(socket.sent.at(-1)!)).toMatchObject({ t: 'source/close' }) + expect(socket.readyState).toBe(FakeWebSocket.CLOSED) + }) + + it('keeps the realm source id and rotates the transport generation on reconnect', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const firstSocket = FakeWebSocket.sockets[0]! + firstSocket.open() + const firstOpen = JSON.parse(firstSocket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + + firstSocket.close() + await vi.waitFor(() => { expect(FakeWebSocket.sockets).toHaveLength(2) }) + const secondSocket = FakeWebSocket.sockets[1]! + secondSocket.open() + const secondOpen = JSON.parse(secondSocket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + expect(secondOpen.source.sourceId).toBe(firstOpen.source.sourceId) + expect(secondOpen.source.generation).not.toBe(firstOpen.source.generation) + + await fiber.dispose() + }) + + it('does not report queue loss again after a replacement absorbs it', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = { ...bootstrap, maxQueuedRecords: 1 } + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + + ctx.inspector.publish('client/first', { ordinal: 1 }) + ctx.inspector.publish('client/second', { ordinal: 2 }) + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + + const replacement = JSON.parse(socket.sent[1]!) as { nextSequence: number } + const append = JSON.parse(socket.sent[2]!) as { + firstSequence: number + droppedBefore: number + records: Array<{ topic: string }> + } + expect(append).toMatchObject({ + firstSequence: replacement.nextSequence, + droppedBefore: 0, + records: [{ topic: 'client/second' }], + }) + + await fiber.dispose() + }) + + it('discovers and serves its built Client bundle through the source protocol', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + Reflect.set(globalThis, '__DSH_BOOT__', { + rev: 'graph', + entries: [{ + id: '@deepseek-ai/dsh-experimental-inspector', + url: '/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev', + rev: 'bundle-rev', + }], + }) + const source = 'const clientBundleMarker = "你好"\n' + const sourceMap = '{"version":3,"sources":["client/index.ts"]}' + globalThis.fetch = vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url + return new Response(url.includes('.js.map') ? sourceMap : source) + }) + + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string; capabilities: Array<{ type: string }> } + } + expect(open.source.capabilities).toEqual(expect.arrayContaining([{ type: 'client-sources' }])) + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + socket.receive({ + v: 0, + t: 'client-sources/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'source-session-1', + requestId: 'source-request-1', + command: { op: 'list-scripts' }, + }) + + let scriptKey: string | undefined + await vi.waitFor(() => { + const response = socket.sent.map(value => JSON.parse(value) as { + requestId?: string + outcome?: { result?: { scripts?: Array<{ scriptKey: string; url: string; sourceMapUrl: string }> } } + }).find(frame => frame.requestId === 'source-request-1') + const script = response?.outcome?.result?.scripts?.[0] + expect(script?.url).toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js?rev=bundle-rev') + expect(script?.sourceMapUrl) + .toContain('/plugins/@deepseek-ai/dsh-experimental-inspector/client.js.map?rev=bundle-rev') + scriptKey = script?.scriptKey + }) + socket.receive({ + v: 0, + t: 'client-sources/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'source-session-1', + requestId: 'source-request-2', + command: { op: 'get-content-chunk', scriptKey, content: 'source', offset: 0, maxBytes: 1_024 }, + }) + await vi.waitFor(() => { + const response = socket.sent.map(value => JSON.parse(value) as { + requestId?: string + outcome?: { result?: { data?: string; eof?: boolean } } + }).find(frame => frame.requestId === 'source-request-2') + expect(response?.outcome?.result?.eof).toBe(true) + const bytes = Uint8Array.from(atob(response?.outcome?.result?.data ?? ''), character => character.charCodeAt(0)) + expect(new TextDecoder().decode(bytes)).toBe(source) + }) + + await fiber.dispose() + }) + + it('fails loud when the Host did not inject a bootstrap', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await expect(fiber).rejects.toThrow('Host bootstrap is missing') + await fiber.dispose() + }) +}) diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts new file mode 100644 index 0000000000..f69a9cc509 --- /dev/null +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -0,0 +1,136 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { Context } from '@deepseek-ai/cordis' +import type { IndexInjection, WebServer } from '@deepseek-ai/dsh-host-webserver' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apply, Config, inject, name, startInspector } from '../src/index.ts' +import { isPlainObject } from '../src/shared/json.ts' + +interface CdpResponse { + readonly id: number + readonly result?: Record +} + +describe('experimental Inspector Host plugin', () => { + let context: Context | undefined + + afterEach(async () => { + await context?.fiber.dispose() + context = undefined + vi.restoreAllMocks() + }) + + it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + context = new Context() + context.provide('webServer', {} as WebServer) + const fiber = context.plugin( + { name, inject: [...inject], Config, apply }, + { port: 0, captureFetch: false }, + ) + await fiber.await() + + const rows: IndexInjection[] = [] + context.emit('webserver/index-inject', rows) + const bootstrap = rows.find(row => row.kind === 'global' && row.name === '__DSH_INSPECTOR__') + expect(bootstrap).toMatchObject({ kind: 'global', name: '__DSH_INSPECTOR__' }) + expect(log).toHaveBeenCalledWith(expect.stringMatching(/^dsh inspector: devtools:\/\//u)) + expect(context.inspector).toBeDefined() + await vi.waitFor(async () => { + const tree = await context!.inspector.cordis.getTree() + expect(tree.host?.source.kind).toBe('host') + }) + expect(() => { context!.inspector.publish('', {}) }).toThrow('topic must contain 1 to 128 characters') + expect(() => { context!.inspector.publish('host/invalid-time', {}, Number.NaN) }).toThrow('monotonicMs must be finite') + context.inspector.publish('host/plugin-probe', { ready: true }) + + const value = bootstrap?.kind === 'global' ? bootstrap.value : undefined + const endpoint = value as { endpoint: string; protocol: string } + const authority = new URL(endpoint.endpoint) + const targets: unknown = await fetch(`http://${authority.host}/json`).then(response => response.json()) + if (!Array.isArray(targets) || !isPlainObject(targets[0]) || typeof targets[0].webSocketDebuggerUrl !== 'string') { + throw new Error('Inspector discovery did not return a target') + } + const socket = new WebSocket(targets[0].webSocketDebuggerUrl) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + const response = new Promise((resolve) => { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpResponse + if (message.id === 1) resolve(message) + }) + }) + socket.send(JSON.stringify({ id: 1, method: 'DSHInspector.getSources' })) + await vi.waitFor(async () => { + const sources = (await response).result?.sources as Array<{ topics: Record }> + expect(sources.some(source => source.topics['host/plugin-probe'] === 1)).toBe(true) + }) + socket.close() + await new Promise((resolve) => { socket.once('close', () => { resolve() }) }) + + await fiber.dispose() + expect(rows).toHaveLength(1) + const afterDispose: IndexInjection[] = [] + context.emit('webserver/index-inject', afterDispose) + expect(afterDispose).toEqual([]) + }) + + it('closes the started Worker when a later plugin registration fails', async () => { + const port = await availablePort() + context = new Context() + context.provide('webServer', {} as WebServer) + context.provide('inspector', { + publish: () => undefined, + cordis: { getTree: () => Promise.reject(new Error('unused test service')) }, + }) + + const fiber = context.plugin( + { name, inject: [...inject], Config, apply }, + { port, captureFetch: false }, + ) + await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered') + + const replacement = await startInspector({ port, captureFetch: false }) + expect(new URL(replacement.endpoint.httpUrl).port).toBe(String(port)) + await replacement.close() + }) + + it('closes the Worker when fetch capture installation fails', async () => { + const port = await availablePort() + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch') + const nativeFetch = globalThis.fetch + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + get: () => nativeFetch, + }) + try { + await expect(startInspector({ port })).rejects.toThrow('globalThis.fetch is an accessor') + } finally { + if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch') + else Object.defineProperty(globalThis, 'fetch', descriptor) + } + + const replacement = await startInspector({ port, captureFetch: false }) + expect(new URL(replacement.endpoint.httpUrl).port).toBe(String(port)) + await replacement.close() + }) +}) + +async function availablePort(): Promise { + const server = createServer() + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const port = (server.address() as AddressInfo).port + await new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }) + return port +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} diff --git a/packages/experimental/inspector/tests/port-selection.host.spec.ts b/packages/experimental/inspector/tests/port-selection.host.spec.ts new file mode 100644 index 0000000000..3b400b29f3 --- /dev/null +++ b/packages/experimental/inspector/tests/port-selection.host.spec.ts @@ -0,0 +1,42 @@ +/** Host Worker port-selection behavior. */ + +import { createServer, type Server } from 'node:http' +import { afterEach, describe, expect, it } from 'vitest' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' + +describe('Inspector endpoint port selection', () => { + let blocker: Server | undefined + let inspector: InspectorHandle | undefined + + afterEach(async () => { + await inspector?.close() + inspector = undefined + if (blocker?.listening === true) { + await new Promise((resolve) => { blocker!.close(() => { resolve() }) }) + } + blocker = undefined + }) + + it('advances from an occupied starting port and publishes the selected port', async () => { + blocker = createServer() + await new Promise((resolve, reject) => { + blocker!.once('error', reject) + blocker!.listen(0, '127.0.0.1', () => { + blocker!.off('error', reject) + resolve() + }) + }) + const occupiedAddress = blocker.address() + if (occupiedAddress === null || typeof occupiedAddress === 'string') { + throw new Error('test server did not bind a TCP port') + } + + inspector = await startInspector({ port: occupiedAddress.port, captureFetch: false }) + const selectedPort = Number(new URL(inspector.endpoint.httpUrl).port) + + expect(selectedPort).toBeGreaterThan(occupiedAddress.port) + expect(new URL(inspector.endpoint.webSocketDebuggerUrl).port).toBe(String(selectedPort)) + expect(new URL(inspector.endpoint.client.endpoint).port).toBe(String(selectedPort)) + await expect(fetch(new URL('json', inspector.endpoint.httpUrl)).then(response => response.status)).resolves.toBe(200) + }) +}) diff --git a/packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts b/packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts new file mode 100644 index 0000000000..3206cb0054 --- /dev/null +++ b/packages/experimental/inspector/tests/worker-lifecycle.host.spec.ts @@ -0,0 +1,35 @@ +/** Host-side Worker lifecycle behavior. */ + +import { Worker } from 'node:worker_threads' +import { describe, expect, it } from 'vitest' +import { InspectorWorkerLifecycle } from '../src/host/bridge/lifecycle.ts' + +describe('Inspector Worker lifecycle', () => { + it('keeps the runtime error listener and treats an already-exited Worker as stopped', async () => { + const worker = new Worker('setImmediate(() => { throw new Error("runtime crash") })', { eval: true }) + const lifecycle = new InspectorWorkerLifecycle(worker) + const failed = new Promise((resolve) => { lifecycle.markRunning(resolve) }) + + await expect(failed).resolves.toMatchObject({ message: 'runtime crash' }) + await expect(lifecycle.stop(100)).resolves.toBeUndefined() + expect(lifecycle.exitCode).toBeTypeOf('number') + }) + + it('reads readiness and completes graceful shutdown through one persistent owner', async () => { + const worker = new Worker([ + "const { parentPort } = require('node:worker_threads')", + "parentPort.postMessage({ type: 'ready', host: '127.0.0.1', port: 9230, targetId: 'test-target' })", + "parentPort.on('message', message => { if (message.type === 'shutdown') process.exit(0) })", + ].join('\n'), { eval: true }) + const lifecycle = new InspectorWorkerLifecycle(worker) + + await expect(lifecycle.waitForReady(1_000)).resolves.toMatchObject({ + host: '127.0.0.1', + port: 9_230, + targetId: 'test-target', + }) + lifecycle.markRunning(() => { throw new Error('graceful exit reported as unexpected') }) + await expect(lifecycle.stop(1_000)).resolves.toBeUndefined() + expect(lifecycle.exitCode).toBe(0) + }) +}) From bcee99e39d29006e1b2391b68d6bc82d5175da26 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:26:09 +0800 Subject: [PATCH 065/130] feat(inspector): serve Runtime through a CDP Worker --- .../inspector/src/worker/bridge/endpoint.ts | 304 ++++++++++ .../inspector/src/worker/bridge/hub.ts | 316 ++++++++++ .../src/worker/bridge/runtime-rpc.ts | 328 +++++++++++ .../inspector/src/worker/bridge/session.ts | 26 + .../inspector/src/worker/bridge/source-rpc.ts | 192 ++++++ .../worker/cdp/domains/debugger/cdp-params.ts | 52 ++ .../src/worker/cdp/domains/debugger/index.ts | 6 + .../worker/cdp/domains/debugger/projector.ts | 114 ++++ .../cdp/domains/debugger/script-registry.ts | 117 ++++ .../worker/cdp/domains/debugger/session.ts | 350 +++++++++++ .../src/worker/cdp/domains/native.ts | 49 ++ .../worker/cdp/domains/runtime/cdp-params.ts | 256 ++++++++ .../src/worker/cdp/domains/runtime/index.ts | 3 + .../cdp/domains/runtime/object-table.ts | 323 +++++++++++ .../src/worker/cdp/domains/runtime/session.ts | 455 +++++++++++++++ .../inspector/src/worker/cdp/ids.ts | 53 ++ .../inspector/src/worker/cdp/protocol.ts | 82 +++ .../src/worker/cdp/realm-sessions.ts | 128 ++++ .../inspector/src/worker/cdp/session.ts | 117 ++++ .../inspector/src/worker/cdp/target.ts | 77 +++ .../inspector/src/worker/entry.ts | 55 ++ .../src/worker/inspection/realm-store.ts | 116 ++++ .../inspector/src/worker/inspection/realm.ts | 54 ++ .../src/worker/realms/client/bridge.ts | 26 + .../src/worker/realms/client/console.ts | 40 ++ .../src/worker/realms/client/debugger.ts | 11 + .../src/worker/realms/client/index.ts | 104 ++++ .../src/worker/realms/client/runtime.ts | 160 +++++ .../src/worker/realms/client/scripts.ts | 27 + .../src/worker/realms/client/sources.ts | 122 ++++ .../src/worker/realms/client/values.ts | 162 ++++++ .../src/worker/realms/host/bridge.ts | 164 ++++++ .../src/worker/realms/host/console.ts | 90 +++ .../src/worker/realms/host/debugger.ts | 167 ++++++ .../inspector/src/worker/realms/host/index.ts | 67 +++ .../src/worker/realms/host/runtime.ts | 322 +++++++++++ .../src/worker/realms/host/scripts.ts | 13 + .../src/worker/realms/host/sources.ts | 99 ++++ .../src/worker/realms/host/values.ts | 34 ++ .../inspector/src/worker/server.ts | 111 ++++ .../inspector/tests/built-lib.e2e.ts | 56 ++ .../inspector/tests/client-browser.e2e.ts | 257 +++++++++ .../inspector/tests/client-stack.host.spec.ts | 30 + .../inspector/tests/debugger.e2e.ts | 198 +++++++ .../tests/fixtures/client-source.client.ts | 114 ++++ .../tests/fixtures/client-source.host.ts | 141 +++++ .../inspector/tests/fixtures/debug-host.ts | 28 + .../inspector/tests/integration.host.spec.ts | 545 ++++++++++++++++++ 48 files changed, 6661 insertions(+) create mode 100644 packages/experimental/inspector/src/worker/bridge/endpoint.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/hub.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/session.ts create mode 100644 packages/experimental/inspector/src/worker/bridge/source-rpc.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/native.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/ids.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/protocol.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/realm-sessions.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/session.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/target.ts create mode 100644 packages/experimental/inspector/src/worker/entry.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/realm-store.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/realm.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/bridge.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/console.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/debugger.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/index.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/runtime.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/scripts.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/sources.ts create mode 100644 packages/experimental/inspector/src/worker/realms/client/values.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/bridge.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/console.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/debugger.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/index.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/runtime.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/scripts.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/sources.ts create mode 100644 packages/experimental/inspector/src/worker/realms/host/values.ts create mode 100644 packages/experimental/inspector/src/worker/server.ts create mode 100644 packages/experimental/inspector/tests/built-lib.e2e.ts create mode 100644 packages/experimental/inspector/tests/client-browser.e2e.ts create mode 100644 packages/experimental/inspector/tests/client-stack.host.spec.ts create mode 100644 packages/experimental/inspector/tests/debugger.e2e.ts create mode 100644 packages/experimental/inspector/tests/fixtures/client-source.client.ts create mode 100644 packages/experimental/inspector/tests/fixtures/client-source.host.ts create mode 100644 packages/experimental/inspector/tests/fixtures/debug-host.ts create mode 100644 packages/experimental/inspector/tests/integration.host.spec.ts diff --git a/packages/experimental/inspector/src/worker/bridge/endpoint.ts b/packages/experimental/inspector/src/worker/bridge/endpoint.ts new file mode 100644 index 0000000000..eaec14aa11 --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/endpoint.ts @@ -0,0 +1,304 @@ +/** Worker-owned HTTP discovery, DevTools CDP, and Client-ingest endpoints. */ + +import { createServer, type IncomingMessage, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { Duplex } from 'node:stream' +import { WebSocketServer, type RawData, type WebSocket } from 'ws' +import type { InspectorWorkerConfig } from '../../shared/bridge/messages/control.ts' +import type { WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' +import { CdpSession } from '../cdp/session.ts' +import type { CdpTransport } from '../cdp/protocol.ts' +import type { NetworkDomain } from '../cdp/domains/network/session.ts' +import type { CordisDomBackend } from '../cdp/domains/dom/index.ts' +import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts' +import type { InspectorQueryRouter } from '../inspection/query-router.ts' +import type { InspectorRealmRegistry } from '../inspection/realm-store.ts' +import type { InspectorSourceRegistry, SourceConnection } from './hub.ts' + +/** Bound endpoint information returned to the Host controller. */ +export interface InspectorEndpointInfo { + readonly host: string + readonly port: number + readonly targetId: string +} + +/** Worker-owned network endpoint. */ +export class InspectorEndpoint { + private server: Server | undefined + private readonly cdpServer: WebSocketServer + private readonly ingestServer: WebSocketServer + private readonly cdpSessions = new Map() + private readonly ingestConnections = new Map() + + constructor( + private readonly config: InspectorWorkerConfig, + private readonly sources: InspectorSourceRegistry, + private readonly network: NetworkDomain, + private readonly realms: InspectorRealmRegistry, + private readonly cordisDom: CordisDomBackend, + private readonly cordisTrees: CordisRuntimeTreeReader, + private readonly queries: InspectorQueryRouter, + ) { + this.cdpServer = new WebSocketServer({ noServer: true, maxPayload: config.maxSourceFrameBytes }) + this.ingestServer = new WebSocketServer({ noServer: true, maxPayload: config.maxSourceFrameBytes }) + } + + /** + * Bind the loopback endpoint. + * @returns The actual bound address and target id. + */ + async start(): Promise { + let candidate = this.config.startPort + while (true) { + const server = this.createServer() + this.server = server + try { + const address = await listen(server, candidate, this.config.host) + server.on('error', () => { + // An established server error is connection-local or reported by + // the operating system; active sockets retain their own handlers. + }) + return { host: this.config.host, port: address.port, targetId: this.config.targetId } + } catch (error) { + this.server = undefined + if (!isAddressInUse(error) || candidate === 0) throw error + if (candidate === 65_535) { + throw new Error(`inspector: no available port from ${String(this.config.startPort)} through 65535`, { + cause: error, + }) + } + candidate += 1 + } + } + } + + /** Stop admission, dispose CDP sessions, terminate sockets, and await server close. */ + async close(): Promise { + const server = this.requireServer() + for (const [socket, session] of this.cdpSessions) { + session.close() + socket.terminate() + } + this.cdpSessions.clear() + for (const [socket, connection] of this.ingestConnections) { + this.sources.disconnect(connection, 'Client ingest endpoint stopped') + socket.terminate() + } + this.ingestConnections.clear() + await Promise.all([ + closeWebSocketServer(this.cdpServer), + closeWebSocketServer(this.ingestServer), + new Promise((resolve) => { + server.close(() => { resolve() }) + server.closeAllConnections() + }), + ]) + } + + private handleHttp(request: IncomingMessage, response: import('node:http').ServerResponse): void { + const pathname = new URL(request.url ?? '/', 'http://inspector.invalid').pathname + if (pathname === '/json' || pathname === '/json/list') { + this.json(response, [this.target()]) + return + } + if (pathname === '/json/version') { + this.json(response, { + Browser: 'dsh-experimental-inspector/0', + 'Protocol-Version': '1.3', + webSocketDebuggerUrl: this.cdpUrl(), + }) + return + } + response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }) + response.end('not found') + } + + private handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void { + let pathname: string + try { + pathname = new URL(request.url ?? '/', 'http://inspector.invalid').pathname + } catch { + socket.destroy() + return + } + if (pathname === `/devtools/page/${this.config.targetId}`) { + this.cdpServer.handleUpgrade(request, socket, head, (ws) => { this.acceptCdp(ws) }) + return + } + if (pathname === '/ingest') { + if (!this.authorizedClient(request)) { + socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') + return + } + this.ingestServer.handleUpgrade(request, socket, head, (ws) => { this.acceptIngest(ws) }) + return + } + socket.destroy() + } + + private acceptCdp(socket: WebSocket): void { + const transport: CdpTransport = { + send: (payload) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(payload)) + }, + close: () => { socket.close(1008, 'invalid CDP request') }, + } + const session = new CdpSession( + transport, + { targetId: this.config.targetId, title: 'DeepSeek Harness Host' }, + this.sources, + this.network, + this.realms, + this.cordisDom, + this.cordisTrees, + ) + this.cdpSessions.set(socket, session) + socket.on('message', (data) => { + try { + session.receive(JSON.parse(rawText(data)) as unknown) + } catch { + socket.close(1008, 'CDP frame must be JSON') + } + }) + socket.once('close', () => { + this.cdpSessions.delete(socket) + session.close() + }) + socket.on('error', () => { + // The close event performs connection-owned cleanup. + }) + } + + private acceptIngest(socket: WebSocket): void { + const queryPeer = this.queries.open({ + send: (frame) => { + if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(frame)) + }, + close: (code, reason) => { socket.close(code, reason) }, + }) + const connection: SourceConnection = { + kind: 'client', + send: (frame: WorkerToSourceFrame) => { + if (socket.readyState !== socket.OPEN) return + socket.send(JSON.stringify(frame)) + if (frame.t === 'source/accepted') queryPeer.accept(frame.sourceId, frame.generation) + }, + close: (code, reason) => { socket.close(code, reason.slice(0, 123)) }, + } + this.ingestConnections.set(socket, connection) + socket.on('message', (data) => { + try { + const value = JSON.parse(rawText(data)) as unknown + if (!queryPeer.receive(value)) this.sources.receive(connection, value) + } catch { + connection.close(1008, 'source frame must be JSON') + } + }) + socket.once('close', () => { + this.ingestConnections.delete(socket) + queryPeer.close() + this.sources.disconnect(connection, 'Client source disconnected') + }) + socket.on('error', () => { + // The close event performs connection-owned cleanup. + }) + } + + private authorizedClient(request: IncomingMessage): boolean { + const protocols = (request.headers['sec-websocket-protocol'] ?? '') + .split(',') + .map(value => value.trim()) + if (!protocols.includes(this.config.clientToken)) return false + const origin = request.headers.origin + if (origin === undefined) return true + if (this.config.clientOrigins.includes(origin)) return true + try { + const hostname = new URL(origin).hostname + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' + } catch { + return false + } + } + + private target(): object { + return { + id: this.config.targetId, + type: 'page', + title: 'DeepSeek Harness Host', + description: 'Experimental cross-realm Inspector target', + url: 'dsh://host', + webSocketDebuggerUrl: this.cdpUrl(), + devtoolsFrontendUrl: `devtools://devtools/bundled/devtools_app.html?ws=${this.config.host}:${this.boundPort()}/devtools/page/${this.config.targetId}&panel=elements&noJavaScriptCompletion=true`, + } + } + + private cdpUrl(): string { + return `ws://${this.config.host}:${String(this.boundPort())}/devtools/page/${this.config.targetId}` + } + + private boundPort(): number { + const address = this.requireServer().address() + if (address === null || typeof address === 'string') { + throw new Error('inspector: endpoint is not bound to a TCP port') + } + return address.port + } + + private createServer(): Server { + const server = createServer((request, response) => { this.handleHttp(request, response) }) + server.on('upgrade', (request, socket, head) => { this.handleUpgrade(request, socket, head) }) + return server + } + + private requireServer(): Server { + if (this.server === undefined) throw new Error('inspector: endpoint is not started') + return this.server + } + + private json(response: import('node:http').ServerResponse, value: unknown): void { + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + response.end(JSON.stringify(value)) + } +} + +function listen(server: Server, port: number, host: string): Promise { + return new Promise((resolve, reject) => { + const finish = (): void => { + server.off('error', onError) + server.off('listening', onListening) + } + const onError = (error: Error): void => { + finish() + reject(error) + } + const onListening = (): void => { + finish() + const address = server.address() + if (address === null || typeof address === 'string') { + reject(new Error('inspector: endpoint did not bind a TCP port')) + return + } + resolve(address) + } + server.once('error', onError) + server.once('listening', onListening) + server.listen(port, host) + }) +} + +function isAddressInUse(error: unknown): boolean { + return error instanceof Error && (error as NodeJS.ErrnoException).code === 'EADDRINUSE' +} + +function rawText(data: RawData): string { + const bytes = data instanceof ArrayBuffer + ? Buffer.from(new Uint8Array(data)) + : Array.isArray(data) ? Buffer.concat(data) : data + return bytes.toString('utf8') +} + +function closeWebSocketServer(server: WebSocketServer): Promise { + return new Promise((resolve) => { + server.close(() => { resolve() }) + }) +} diff --git a/packages/experimental/inspector/src/worker/bridge/hub.ts b/packages/experimental/inspector/src/worker/bridge/hub.ts new file mode 100644 index 0000000000..dfdffc2eee --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/hub.ts @@ -0,0 +1,316 @@ +/** Worker-owned source generations, observation dispatch, and extension transport. */ + +import { jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts' +import { + INSPECTOR_PROTOCOL_VERSION, + parseSourceFrame, + type InspectorRecordInput, + type InspectorSourceDescriptor, + type InspectorSourceKind, + type SourceToWorkerFrame, + type WorkerToSourceFrame, +} from '../../shared/bridge/messages/observation.ts' +import type { ClientConsoleEventFrame, ClientRuntimeResponseFrame } from '../../shared/bridge/messages/runtime/index.ts' +import type { ClientSourceResponseFrame } from '../../shared/bridge/messages/sources/index.ts' + +/** One validated record with its source-local sequence. */ +export interface IngestedInspectorRecord extends InspectorRecordInput { + readonly sequence: number +} + +/** One connected source's reply and close operations. */ +export interface SourceConnection { + readonly kind: InspectorSourceKind + send(frame: WorkerToSourceFrame): void + close(code: number, reason: string): void +} + +/** Consumer of source lifecycle and records. */ +export interface InspectorRecordConsumer { + readonly topics: ReadonlySet + replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void + append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void + close(source: InspectorSourceDescriptor, reason: string): void +} + +interface SourceState { + readonly source: InspectorSourceDescriptor + readonly topics: ReadonlySet + readonly connection: SourceConnection + expectedSequence: number + dropped: number + readonly topicCounts: Map +} + +/** Source lifecycle and typed extension frames observed inside the Worker. */ +export type InspectorSourceEvent = + | { readonly type: 'opened'; readonly source: InspectorSourceDescriptor } + | { readonly type: 'closed'; readonly source: InspectorSourceDescriptor; readonly reason: string } + | { + readonly type: 'client-runtime-response' + readonly source: InspectorSourceDescriptor + readonly frame: ClientRuntimeResponseFrame + } + | { + readonly type: 'client-console-event' + readonly source: InspectorSourceDescriptor + readonly frame: ClientConsoleEventFrame + } + | { + readonly type: 'client-source-response' + readonly source: InspectorSourceDescriptor + readonly frame: ClientSourceResponseFrame + } + +/** Read-only diagnostic for `DSHInspector.getSources`. */ +export interface InspectorSourceView { + readonly sourceId: string + readonly generation: string + readonly kind: InspectorSourceKind + readonly label: string + readonly capabilities: readonly string[] + readonly expectedSequence: number + readonly dropped: number + readonly topics: Readonly> +} + +/** Serial Worker-side owner of every Host and Client source generation. */ +export class InspectorSourceRegistry { + private readonly sources = new Map() + private readonly statusListeners = new Set<() => void>() + private readonly eventListeners = new Set<(event: InspectorSourceEvent) => void>() + + constructor( + private readonly consumers: readonly InspectorRecordConsumer[], + private readonly maxFrameBytes: number, + private readonly maxRecordsPerFrame: number, + ) {} + + /** + * Parse and apply one frame; malformed input closes only its source transport. + * @param connection - Carrier that delivered the frame. + * @param value - Untrusted decoded frame. + */ + receive(connection: SourceConnection, value: unknown): void { + try { + const frame = parseSourceFrame(value, this.maxRecordsPerFrame) + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) { + throw new Error(`inspector protocol: source frame exceeds ${String(this.maxFrameBytes)} bytes`) + } + this.apply(connection, frame) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + connection.send({ v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: 'invalid-frame', message }) + connection.close(1008, message) + } + } + + /** + * Remove every generation carried by a closed connection. + * @param connection - Closed source carrier. + * @param reason - Diagnostic propagated to domain consumers. + */ + disconnect(connection: SourceConnection, reason: string): void { + for (const [sourceId, state] of this.sources) { + if (state.connection !== connection) continue + this.sources.delete(sourceId) + for (const consumer of this.consumers) consumer.close(state.source, reason) + this.emit({ type: 'closed', source: state.source, reason }) + } + this.notifyStatus() + } + + /** + * Read current source status for the diagnostic CDP domain. + * @returns A detached status row for every active source. + */ + describe(): InspectorSourceView[] { + return [...this.sources.values()].map(state => ({ + sourceId: state.source.sourceId, + generation: state.source.generation, + kind: state.source.kind, + label: state.source.label, + capabilities: state.source.capabilities.map(capability => capability.type), + expectedSequence: state.expectedSequence, + dropped: state.dropped, + topics: Object.fromEntries(state.topicCounts), + })) + } + + /** + * Subscribe to source status changes. + * @param listener - Status observer. + * @returns A disposer that removes the observer. + */ + subscribeStatus(listener: () => void): () => void { + this.statusListeners.add(listener) + return () => { this.statusListeners.delete(listener) } + } + + /** + * Subscribe to source admission, removal, and typed extension frames. + * @param listener - Source protocol observer. + * @returns A disposer that removes the observer. + */ + subscribeEvents(listener: (event: InspectorSourceEvent) => void): () => void { + this.eventListeners.add(listener) + return () => { this.eventListeners.delete(listener) } + } + + /** + * Send a typed control frame only to its still-active source generation. + * @param source - Expected active source generation. + * @param frame - Validated Worker-to-source frame. + * @returns Whether the generation was still active and accepted the send. + */ + send(source: InspectorSourceDescriptor, frame: WorkerToSourceFrame): boolean { + const state = this.sources.get(source.sourceId) + if (state === undefined || state.source.generation !== source.generation) return false + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) { + throw new Error(`inspector protocol: Worker source frame exceeds ${String(this.maxFrameBytes)} bytes`) + } + state.connection.send(frame) + return true + } + + /** Close every source and forget all state. */ + close(): void { + for (const state of this.sources.values()) { + for (const consumer of this.consumers) consumer.close(state.source, 'inspector worker stopped') + this.emit({ type: 'closed', source: state.source, reason: 'inspector worker stopped' }) + } + this.sources.clear() + this.notifyStatus() + } + + private apply(connection: SourceConnection, frame: SourceToWorkerFrame): void { + if (frame.t === 'source/open') { + this.open(connection, frame.source, frame.topics) + return + } + const state = this.sources.get(frame.sourceId) + if (state === undefined || state.connection !== connection || state.source.generation !== frame.generation) { + throw new Error('inspector protocol: frame does not belong to the active source generation') + } + if (frame.t === 'source/close') { + this.sources.delete(frame.sourceId) + for (const consumer of this.consumers) consumer.close(state.source, 'source closed') + this.emit({ type: 'closed', source: state.source, reason: 'source closed' }) + this.notifyStatus() + return + } + if (frame.t === 'client-runtime/response') { + if (state.source.kind !== 'client' + || !state.source.capabilities.some(capability => capability.type === 'client-runtime')) { + throw new Error('inspector protocol: source did not declare Client Runtime') + } + this.emit({ type: 'client-runtime-response', source: state.source, frame }) + return + } + if (frame.t === 'client-console/event') { + if (state.source.kind !== 'client' + || !state.source.capabilities.some(capability => capability.type === 'client-console')) { + throw new Error('inspector protocol: source did not declare Client Console') + } + this.emit({ type: 'client-console-event', source: state.source, frame }) + return + } + if (frame.t === 'client-sources/response') { + if (state.source.kind !== 'client' + || !state.source.capabilities.some(capability => capability.type === 'client-sources')) { + throw new Error('inspector protocol: source did not declare Client Sources') + } + this.emit({ type: 'client-source-response', source: state.source, frame }) + return + } + this.assertTopics(state, frame.records) + if (frame.t === 'source/replace') { + state.expectedSequence = frame.nextSequence + for (const consumer of this.consumers) consumer.replace( + state.source, + frame.records.map((record, index) => ({ ...record, sequence: frame.nextSequence + index })), + ) + this.count(state, frame.records) + this.notifyStatus() + return + } + const gap = frame.firstSequence - state.expectedSequence + if (gap < 0 || gap !== frame.droppedBefore) { + connection.send({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/resnapshot', + sourceId: state.source.sourceId, + generation: state.source.generation, + expectedSequence: state.expectedSequence, + reason: `expected sequence ${String(state.expectedSequence)}, received ${String(frame.firstSequence)}`, + }) + return + } + state.dropped += frame.droppedBefore + const records = frame.records.map((record, index) => ({ ...record, sequence: frame.firstSequence + index })) + state.expectedSequence = frame.firstSequence + frame.records.length + for (const consumer of this.consumers) consumer.append(state.source, records) + this.count(state, frame.records) + this.notifyStatus() + } + + private open(connection: SourceConnection, source: InspectorSourceDescriptor, topics: readonly string[]): void { + if (source.kind !== connection.kind) throw new Error('inspector protocol: source kind does not match its carrier') + const accepted = new Set(topics) + const prior = this.sources.get(source.sourceId) + if (prior !== undefined) { + for (const consumer of this.consumers) consumer.close(prior.source, 'source generation replaced') + this.emit({ type: 'closed', source: prior.source, reason: 'source generation replaced' }) + } + this.sources.set(source.sourceId, { + source, + topics: accepted, + connection, + expectedSequence: 1, + dropped: 0, + topicCounts: new Map(), + }) + connection.send({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/accepted', + sourceId: source.sourceId, + generation: source.generation, + }) + this.emit({ type: 'opened', source }) + this.notifyStatus() + } + + private assertTopics(state: SourceState, records: readonly InspectorRecordInput[]): void { + for (const record of records) { + if (!state.topics.has('*') && !state.topics.has(record.topic)) { + throw new Error(`inspector protocol: source did not declare topic ${JSON.stringify(record.topic)}`) + } + } + } + + private count(state: SourceState, records: readonly InspectorRecordInput[]): void { + for (const record of records) { + state.topicCounts.set(record.topic, (state.topicCounts.get(record.topic) ?? 0) + 1) + } + } + + private notifyStatus(): void { + for (const listener of [...this.statusListeners]) { + try { + listener() + } catch { + // A diagnostic observer is isolated from source admission and later observers. + } + } + } + + private emit(event: InspectorSourceEvent): void { + for (const listener of [...this.eventListeners]) { + try { + listener(event) + } catch { + // A protocol consumer is isolated from source admission and sibling consumers. + } + } + } +} diff --git a/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts new file mode 100644 index 0000000000..e4e66c5031 --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts @@ -0,0 +1,328 @@ +/** Worker-owned routing between synthetic Client contexts and source generations. */ + +import { randomUUID } from 'node:crypto' +import type { + ClientConsoleEventFrame, + ClientRuntimeCapability, + ClientRuntimeCommand, + ClientRuntimeError, + ClientRuntimeResponseFrame, + ClientRuntimeResult, +} from '../../shared/bridge/messages/runtime/index.ts' +import { + inspectorId, + type ClientRemoteObjectHandle, + type ClientRuntimeRequestId, + type ClientRuntimeSessionId, +} from '../../shared/bridge/ids.ts' +import { INSPECTOR_PROTOCOL_VERSION, type InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { sendClientSessionClosed } from './session.ts' +import type { InspectorSourceEvent, InspectorSourceRegistry } from './hub.ts' +import type { RuntimeConsoleBackendEvent } from '../../shared/cdp/index.ts' + +/** One connected projection of a Client realm into a synthetic CDP execution context. */ +export interface ClientRuntimeTarget { + readonly contextId: number + readonly uniqueContextId: string + readonly source: InspectorSourceDescriptor + readonly capability: ClientRuntimeCapability +} + +/** Runtime target admission or removal. */ +export type ClientRuntimeTargetEvent = + | { readonly type: 'opened'; readonly target: ClientRuntimeTarget } + | { readonly type: 'closed'; readonly target: ClientRuntimeTarget } + +interface PendingRequest { + readonly target: ClientRuntimeTarget + readonly sessionId: ClientRuntimeSessionId + readonly op: ClientRuntimeCommand['op'] + readonly resolve: (result: ClientRuntimeResult) => void + readonly reject: (error: Error) => void + readonly timer: ReturnType +} + +interface ConsoleSubscription { + readonly target: ClientRuntimeTarget + readonly sessionId: ClientRuntimeSessionId + readonly listener: (event: RuntimeConsoleBackendEvent) => void +} + +/** Error returned deliberately by the Client Runtime executor. */ +export class ClientRuntimeRemoteError extends Error { + constructor(readonly code: ClientRuntimeError['code'], message: string) { + super(message) + } +} + +/** Runtime context registry and correlated Worker-to-Client request owner. */ +export class ClientRuntimeRouter { + private readonly targetsBySource = new Map() + private readonly pending = new Map() + private readonly consoleSubscriptions = new Set() + private readonly listeners = new Set<(event: ClientRuntimeTargetEvent) => void>() + private readonly unsubscribeSources: () => void + private nextContextId = -1 + private closed = false + + constructor(private readonly sources: InspectorSourceRegistry, private readonly timeoutMs: number) { + this.unsubscribeSources = sources.subscribeEvents((event) => { this.receiveSourceEvent(event) }) + } + + /** + * Snapshot all active Client execution contexts. + * @returns Active targets in admission order. + */ + targets(): ClientRuntimeTarget[] { + return [...this.targetsBySource.values()] + } + + /** + * Resolve the Client target for one active source generation. + * @param source - Source identity stored with a semantic node. + * @returns Its active Runtime target, when the generation still matches. + */ + bySource(source: InspectorSourceDescriptor): ClientRuntimeTarget | undefined { + const target = this.targetsBySource.get(source.sourceId) + return target?.source.generation === source.generation ? target : undefined + } + + /** + * Subscribe to synthetic execution-context lifecycle. + * @param listener - Context lifecycle observer. + * @returns A disposer that removes the observer. + */ + subscribe(listener: (event: ClientRuntimeTargetEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Enable Console events for one Client realm and DevTools session. + * @param target - Active Client realm. + * @param sessionId - DevTools Runtime session retaining event arguments. + * @param listener - Consumer of validated Client Console events. + * @returns A disposer that disables this Console session. + */ + subscribeConsole( + target: ClientRuntimeTarget, + sessionId: ClientRuntimeSessionId, + listener: (event: RuntimeConsoleBackendEvent) => void, + ): () => void { + const subscription: ConsoleSubscription = { target, sessionId, listener } + if (!this.sources.send(target.source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-console/enable', + sourceId: target.source.sourceId, + generation: target.source.generation, + sessionId, + })) { + throw new Error('Client Console source disconnected before enable') + } + this.consoleSubscriptions.add(subscription) + return () => { + if (!this.consoleSubscriptions.delete(subscription)) return + try { + this.sources.send(target.source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-console/disable', + sourceId: target.source.sourceId, + generation: target.source.generation, + sessionId, + }) + } catch { + // Source removal also disables Console observation in the Client. + } + } + } + + /** + * Execute one typed command in its currently active source generation. + * @param target - Active Client source and context. + * @param sessionId - Calling DevTools Runtime session. + * @param command - Validated Client Runtime operation. + * @returns The correlated result, or a rejection on timeout or disconnect. + */ + request( + target: ClientRuntimeTarget, + sessionId: ClientRuntimeSessionId, + command: ClientRuntimeCommand, + ): Promise { + if (this.closed || this.targetsBySource.get(target.source.sourceId) !== target) { + return Promise.reject(new Error('Client execution context is no longer available')) + } + const requestId = inspectorId<'ClientRuntimeRequestId'>(randomUUID(), 'requestId') + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId) + reject(new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`)) + }, this.timeoutMs) + timer.unref() + this.pending.set(requestId, { target, sessionId, op: command.op, resolve, reject, timer }) + try { + const sent = this.sources.send(target.source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/request', + sourceId: target.source.sourceId, + generation: target.source.generation, + sessionId, + requestId, + command, + }) + if (!sent) this.rejectPending(requestId, new Error('Client execution context disconnected before dispatch')) + } catch (error) { + this.rejectPending(requestId, renderError(error)) + } + }) + } + + /** + * Close one realm-local Runtime session without notifying sibling Client realms. + * @param target - Client realm that owns the session. + * @param sessionId - Closing DevTools Runtime session. + */ + closeTargetSession(target: ClientRuntimeTarget, sessionId: ClientRuntimeSessionId): void { + for (const [requestId, pending] of this.pending) { + if (pending.target !== target || pending.sessionId !== sessionId) continue + this.rejectPending(requestId, new Error('DevTools Runtime session closed')) + } + for (const subscription of [...this.consoleSubscriptions]) { + if (subscription.target === target && subscription.sessionId === sessionId) { + this.consoleSubscriptions.delete(subscription) + } + } + sendClientSessionClosed(this.sources, target.source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/session-closed', + sourceId: target.source.sourceId, + generation: target.source.generation, + sessionId, + }) + } + + /** Stop routing and reject every outstanding operation. */ + close(): void { + if (this.closed) return + this.closed = true + this.unsubscribeSources() + for (const requestId of [...this.pending.keys()]) { + this.rejectPending(requestId, new Error('Client Runtime router closed')) + } + this.targetsBySource.clear() + this.consoleSubscriptions.clear() + this.listeners.clear() + } + + private receiveSourceEvent(event: InspectorSourceEvent): void { + switch (event.type) { + case 'opened': + this.open(event.source) + return + case 'closed': + this.remove(event.source, event.reason) + return + case 'client-runtime-response': + this.settle(event.source, event.frame) + return + case 'client-console-event': + this.consoleEvent(event.source, event.frame) + return + case 'client-source-response': + return + default: + assertNever(event) + } + } + + private open(source: InspectorSourceDescriptor): void { + const capability = source.capabilities.find( + (candidate): candidate is ClientRuntimeCapability => candidate.type === 'client-runtime', + ) + if (capability === undefined) return + const target: ClientRuntimeTarget = { + contextId: this.nextContextId--, + uniqueContextId: `dsh-client:${source.sourceId}:${source.generation}`, + source, + capability, + } + this.targetsBySource.set(source.sourceId, target) + this.emit({ type: 'opened', target }) + } + + private remove(source: InspectorSourceDescriptor, reason: string): void { + const target = this.targetsBySource.get(source.sourceId) + if (target === undefined || target.source.generation !== source.generation) return + this.targetsBySource.delete(source.sourceId) + for (const [requestId, pending] of this.pending) { + if (pending.target !== target) continue + this.rejectPending(requestId, new Error(`Client execution context closed: ${reason}`)) + } + for (const subscription of [...this.consoleSubscriptions]) { + if (subscription.target === target) this.consoleSubscriptions.delete(subscription) + } + this.emit({ type: 'closed', target }) + } + + private consoleEvent(source: InspectorSourceDescriptor, frame: ClientConsoleEventFrame): void { + const target = this.targetsBySource.get(source.sourceId) + if (target === undefined || target.source.generation !== source.generation) return + for (const subscription of [...this.consoleSubscriptions]) { + if (subscription.target !== target || subscription.sessionId !== frame.sessionId) continue + try { + subscription.listener(frame.event) + } catch { + // One DevTools Console session cannot disrupt sibling sessions. + } + } + } + + private settle(source: InspectorSourceDescriptor, frame: ClientRuntimeResponseFrame): void { + const pending = this.pending.get(frame.requestId) + if (pending === undefined) return + if (pending.target.source.sourceId !== source.sourceId + || pending.target.source.generation !== source.generation + || pending.sessionId !== frame.sessionId) { + this.rejectPending(frame.requestId, new Error('Client Runtime response correlation mismatch')) + return + } + if (!frame.outcome.ok) { + this.rejectPending(frame.requestId, new ClientRuntimeRemoteError(frame.outcome.error.code, frame.outcome.error.message)) + return + } + if (frame.outcome.result.op !== pending.op) { + this.rejectPending(frame.requestId, new Error( + `Client Runtime response op ${frame.outcome.result.op} does not match ${pending.op}`, + )) + return + } + clearTimeout(pending.timer) + this.pending.delete(frame.requestId) + pending.resolve(frame.outcome.result) + } + + private rejectPending(requestId: ClientRuntimeRequestId, error: Error): void { + const pending = this.pending.get(requestId) + if (pending === undefined) return + clearTimeout(pending.timer) + this.pending.delete(requestId) + pending.reject(error) + } + + private emit(event: ClientRuntimeTargetEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One CDP session cannot disrupt context delivery to another session. + } + } + } +} + +function renderError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function assertNever(value: never): never { + throw new Error(`Unexpected source event: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/worker/bridge/session.ts b/packages/experimental/inspector/src/worker/bridge/session.ts new file mode 100644 index 0000000000..677ad466ac --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/session.ts @@ -0,0 +1,26 @@ +/** Shared cleanup delivery for Worker-owned Client sessions. */ + +import type { ClientRuntimeSessionClosedFrame } from '../../shared/bridge/messages/runtime/index.ts' +import type { ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorSourceRegistry } from './hub.ts' + +type ClientSessionClosedFrame = ClientRuntimeSessionClosedFrame | ClientSourceSessionClosedFrame + +/** + * Send cleanup to an active Client generation when its transport is still usable. + * @param sources - Worker source registry owning the transport. + * @param source - Generation whose session closed. + * @param frame - Typed Runtime or source-catalog cleanup frame. + */ +export function sendClientSessionClosed( + sources: InspectorSourceRegistry, + source: InspectorSourceDescriptor, + frame: ClientSessionClosedFrame, +): void { + try { + sources.send(source, frame) + } catch { + // Source removal already invalidates every session owned by this generation. + } +} diff --git a/packages/experimental/inspector/src/worker/bridge/source-rpc.ts b/packages/experimental/inspector/src/worker/bridge/source-rpc.ts new file mode 100644 index 0000000000..75f506c69a --- /dev/null +++ b/packages/experimental/inspector/src/worker/bridge/source-rpc.ts @@ -0,0 +1,192 @@ +/** Worker-owned request routing for Client read-only source catalogs. */ + +import { randomUUID } from 'node:crypto' +import type { + ClientSourceCommand, + ClientSourceError, + ClientSourceResponseFrame, + ClientSourceResult, +} from '../../shared/bridge/messages/sources/index.ts' +import { + inspectorId, + type ClientSourceRequestId, + type ClientSourceSessionId, +} from '../../shared/bridge/ids.ts' +import { INSPECTOR_PROTOCOL_VERSION, type InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { sendClientSessionClosed } from './session.ts' +import type { InspectorSourceEvent, InspectorSourceRegistry } from './hub.ts' + +interface PendingSourceRequest { + readonly source: InspectorSourceDescriptor + readonly sessionId: ClientSourceSessionId + readonly command: ClientSourceCommand + readonly resolve: (result: ClientSourceResult) => void + readonly reject: (error: Error) => void + readonly timer: ReturnType +} + +/** Deliberate error returned by the Client source catalog. */ +export class ClientSourceRemoteError extends Error { + constructor(readonly code: ClientSourceError['code'], message: string) { + super(message) + } +} + +/** Correlates bounded source requests with one active Client source generation. */ +export class ClientSourceRouter { + /** Maximum decoded bytes requested in one source-content response. */ + readonly chunkBytes: number + private readonly pending = new Map() + private readonly unsubscribeSources: () => void + private closed = false + + constructor( + private readonly sources: InspectorSourceRegistry, + private readonly timeoutMs: number, + readonly maxContentBytes: number, + maxFrameBytes: number, + ) { + this.chunkBytes = Math.max(1, Math.floor((maxFrameBytes - 4_096) * 3 / 4)) + this.unsubscribeSources = sources.subscribeEvents((event) => { this.receiveSourceEvent(event) }) + } + + /** + * Execute one operation against an active Client source generation. + * @param source - Client source that owns the script catalog. + * @param sessionId - DevTools connection-local source session. + * @param command - Validated read-only source command. + * @returns The correlated result. + */ + request( + source: InspectorSourceDescriptor, + sessionId: ClientSourceSessionId, + command: ClientSourceCommand, + ): Promise { + if (this.closed) return Promise.reject(new Error('Client source router is closed')) + const requestId = inspectorId<'ClientSourceRequestId'>(randomUUID(), 'requestId') + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(requestId) + reject(new Error(`Client source ${command.op} timed out after ${String(this.timeoutMs)}ms`)) + }, this.timeoutMs) + timer.unref() + this.pending.set(requestId, { source, sessionId, command, resolve, reject, timer }) + try { + const sent = this.sources.send(source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/request', + sourceId: source.sourceId, + generation: source.generation, + sessionId, + requestId, + command, + }) + if (!sent) this.rejectPending(requestId, new Error('Client source disconnected before dispatch')) + } catch (error) { + this.rejectPending(requestId, renderError(error)) + } + }) + } + + /** + * Reject pending operations and notify one Client source session that it closed. + * @param source - Source generation owning the session. + * @param sessionId - Closing source session. + */ + closeSession(source: InspectorSourceDescriptor, sessionId: ClientSourceSessionId): void { + for (const [requestId, pending] of this.pending) { + if (pending.source.sourceId !== source.sourceId + || pending.source.generation !== source.generation + || pending.sessionId !== sessionId) continue + this.rejectPending(requestId, new Error('DevTools source session closed')) + } + sendClientSessionClosed(this.sources, source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-sources/session-closed', + sourceId: source.sourceId, + generation: source.generation, + sessionId, + }) + } + + /** Stop routing and reject every outstanding source operation. */ + close(): void { + if (this.closed) return + this.closed = true + this.unsubscribeSources() + for (const requestId of [...this.pending.keys()]) { + this.rejectPending(requestId, new Error('Client source router closed')) + } + } + + private receiveSourceEvent(event: InspectorSourceEvent): void { + switch (event.type) { + case 'closed': + for (const [requestId, pending] of this.pending) { + if (pending.source.sourceId === event.source.sourceId + && pending.source.generation === event.source.generation) { + this.rejectPending(requestId, new Error(`Client source closed: ${event.reason}`)) + } + } + return + case 'client-source-response': + this.settle(event.source, event.frame) + return + case 'opened': + case 'client-runtime-response': + case 'client-console-event': + return + default: + assertNever(event) + } + } + + private settle(source: InspectorSourceDescriptor, frame: ClientSourceResponseFrame): void { + const pending = this.pending.get(frame.requestId) + if (pending === undefined) return + if (pending.source.sourceId !== source.sourceId + || pending.source.generation !== source.generation + || pending.sessionId !== frame.sessionId) { + this.rejectPending(frame.requestId, new Error('Client source response correlation mismatch')) + return + } + if (!frame.outcome.ok) { + this.rejectPending( + frame.requestId, + new ClientSourceRemoteError(frame.outcome.error.code, frame.outcome.error.message), + ) + return + } + if (!matchesCommand(pending.command, frame.outcome.result)) { + this.rejectPending(frame.requestId, new Error('Client source response does not match its request')) + return + } + clearTimeout(pending.timer) + this.pending.delete(frame.requestId) + pending.resolve(frame.outcome.result) + } + + private rejectPending(requestId: ClientSourceRequestId, error: Error): void { + const pending = this.pending.get(requestId) + if (pending === undefined) return + clearTimeout(pending.timer) + this.pending.delete(requestId) + pending.reject(error) + } +} + +function matchesCommand(command: ClientSourceCommand, result: ClientSourceResult): boolean { + if (command.op !== result.op) return false + if (command.op === 'list-scripts' || result.op === 'list-scripts') return true + return result.scriptKey === command.scriptKey + && result.content === command.content + && (!result.available || result.offset === command.offset) +} + +function renderError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} + +function assertNever(value: never): never { + throw new Error(`Unexpected source event: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts new file mode 100644 index 0000000000..8d38c915b2 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/cdp-params.ts @@ -0,0 +1,52 @@ +/** Validation for CDP Debugger requests handled by the shared domain. */ + +import type { RuntimeCallFrameEvaluationRequest } from '../../../../shared/cdp/index.ts' +import { exactKeys, optionalBoolean, optionalString } from '../../../../shared/validation.ts' + +/** + * Parse Debugger.evaluateOnCallFrame without silently accepting unsupported options. + * @param params - Untrusted CDP parameters. + * @returns The common call-frame evaluation request. + */ +export function parseCallFrameEvaluation( + params: Readonly>, +): RuntimeCallFrameEvaluationRequest { + exactKeys(params, [ + 'callFrameId', 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'returnByValue', + 'generatePreview', 'throwOnSideEffect', 'timeout', + ], 'Debugger.evaluateOnCallFrame parameters') + if (typeof params.callFrameId !== 'string' || typeof params.expression !== 'string') { + throw new Error('Debugger.evaluateOnCallFrame requires callFrameId and expression') + } + if (params.timeout !== undefined + && (typeof params.timeout !== 'number' || !Number.isFinite(params.timeout) || params.timeout < 0)) { + throw new Error('Debugger.evaluateOnCallFrame timeout must be a non-negative number') + } + return { + callFrameId: params.callFrameId, + expression: params.expression, + ...optionalString(params, 'objectGroup'), + ...optionalBoolean(params, 'includeCommandLineAPI'), + ...optionalBoolean(params, 'silent'), + ...optionalBoolean(params, 'returnByValue'), + ...optionalBoolean(params, 'generatePreview'), + ...optionalBoolean(params, 'throwOnSideEffect'), + ...(params.timeout === undefined ? {} : { timeoutMs: params.timeout }), + } +} + +/** + * Find a ScriptId carried directly or by a Debugger location parameter. + * @param params - Parsed CDP parameter record. + * @returns The targeted script id when the request names one. + */ +export function requestScriptId(params: Readonly>): string | undefined { + if (typeof params.scriptId === 'string') return params.scriptId + for (const key of ['location', 'start', 'end'] as const) { + const value = params[key] + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const scriptId = (value as Readonly>).scriptId + if (typeof scriptId === 'string') return scriptId + } + return undefined +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts new file mode 100644 index 0000000000..d95fb34a25 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/index.ts @@ -0,0 +1,6 @@ +/** Shared Debugger domain exports. */ + +export * from './cdp-params.ts' +export * from './projector.ts' +export * from './script-registry.ts' +export * from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts new file mode 100644 index 0000000000..23f3a4eced --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/projector.ts @@ -0,0 +1,114 @@ +/** CDP projection for realm-neutral scripts and debugger events. */ + +import type { RuntimeDebuggerEvent, RuntimeDebuggerLocation, RuntimeScript, RuntimeStackTrace } from '../../../../shared/cdp/index.ts' +import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts' +import type { CdpNotification } from '../../protocol.ts' +import type { InspectorRealmSession } from '../../../inspection/realm.ts' +import type { RuntimeDomainSession } from '../runtime/index.ts' +import { cdpScriptId } from './script-registry.ts' + +/** + * Project one common script descriptor to Debugger.scriptParsed. + * @param realm - Realm session that owns the script. + * @param script - Realm-neutral script descriptor. + * @returns A CDP scriptParsed notification. + */ +export function scriptParsedEvent(realm: InspectorRealmSession, script: RuntimeScript): CdpNotification { + return { + method: 'Debugger.scriptParsed', + params: { + scriptId: cdpScriptId(script.scriptKey), + url: script.url, + startLine: script.startLine, + startColumn: script.startColumn, + endLine: script.endLine, + endColumn: script.endColumn, + executionContextId: script.executionContextId + ?? (realm.context.kind === 'synthetic' ? realm.context.id : 0), + hash: script.hash, + buildId: script.buildId ?? '', + ...(script.sourceMapUrl === undefined ? {} : { sourceMapURL: script.sourceMapUrl }), + ...(script.isModule === undefined ? {} : { isModule: script.isModule }), + ...(script.length === undefined ? {} : { length: script.length }), + }, + } +} + +/** + * Project one common debugger event and all nested Runtime objects to CDP. + * @param realm - Realm session that emitted the event. + * @param event - Realm-neutral debugger event. + * @param runtime - Connection-local Runtime object projector. + * @returns The corresponding CDP notification. + */ +export function debuggerEvent( + realm: InspectorRealmSession, + event: RuntimeDebuggerEvent, + runtime: RuntimeDomainSession, +): CdpNotification { + switch (event.type) { + case 'paused': + return { + method: 'Debugger.paused', + params: { + callFrames: event.callFrames.map(frame => ({ + callFrameId: frame.callFrameId, + functionName: frame.functionName, + ...(frame.functionLocation === undefined ? {} : { functionLocation: location(frame.functionLocation) }), + location: location(frame.location), + url: frame.url, + scopeChain: frame.scopeChain.map(scope => ({ + type: scope.type, + object: runtime.projectRemoteObject(realm, scope.object, 'backtrace'), + ...(scope.name === undefined ? {} : { name: scope.name }), + ...(scope.startLocation === undefined ? {} : { startLocation: location(scope.startLocation) }), + ...(scope.endLocation === undefined ? {} : { endLocation: location(scope.endLocation) }), + })), + this: runtime.projectRemoteObject(realm, frame.thisObject, 'backtrace'), + ...(frame.returnValue === undefined + ? {} + : { returnValue: runtime.projectRemoteObject(realm, frame.returnValue, 'backtrace') }), + })), + reason: event.reason, + ...(event.data === undefined ? {} : { data: event.data }), + ...(event.hitBreakpoints === undefined ? {} : { hitBreakpoints: event.hitBreakpoints }), + ...(event.asyncStackTrace === undefined ? {} : { asyncStackTrace: stackTrace(event.asyncStackTrace) }), + }, + } + case 'resumed': + return { method: 'Debugger.resumed', params: {} } + case 'breakpoint-resolved': + return { + method: 'Debugger.breakpointResolved', + params: { breakpointId: event.breakpointId, location: location(event.location) }, + } + default: + return assertNever(event) + } +} + +function location(value: RuntimeDebuggerLocation): Readonly> { + return { + scriptId: cdpScriptId(value.scriptKey), + lineNumber: value.lineNumber, + ...(value.columnNumber === undefined ? {} : { columnNumber: value.columnNumber }), + } +} + +function stackTrace(value: RuntimeStackTrace): Readonly> { + return { + ...(value.description === undefined ? {} : { description: value.description }), + callFrames: value.callFrames.map(frame => ({ + functionName: frame.functionName, + scriptId: frame.scriptKey === undefined ? '0' : cdpScriptId(frame.scriptKey), + url: frame.url, + lineNumber: frame.lineNumber, + columnNumber: frame.columnNumber, + })), + ...(value.parent === undefined ? {} : { parent: stackTrace(value.parent) }), + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected debugger event: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts new file mode 100644 index 0000000000..9639b1bbfa --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/script-registry.ts @@ -0,0 +1,117 @@ +/** Connection-local routing from CDP ScriptId values to realm source backends. */ + +import type { RuntimeScriptKey } from '../../../../shared/cdp/ids.ts' +import type { RuntimeScript } from '../../../../shared/cdp/index.ts' +import type { SourceBackend } from '../../../../shared/cdp/realm.ts' +import type { InspectorRealmSession } from '../../../inspection/realm.ts' +import { cdpStringId, type CdpScriptId } from '../../ids.ts' + +/** One script and the realm source backend that owns its content. */ +export interface DebuggerScriptRoute { + readonly realm: InspectorRealmSession + readonly source: SourceBackend + readonly script: RuntimeScript +} + +/** Tracks active and retired scripts without exposing source transport ids. */ +export class DebuggerScriptRegistry { + private readonly routes = new Map() + private readonly retiredUnsupported = new Set() + + /** + * Register one realm script under its globally unique Runtime script key. + * @param route - Script descriptor and owning realm session. + * @returns The CDP ScriptId and whether this is its first announcement. + */ + register(route: DebuggerScriptRoute): { readonly scriptId: CdpScriptId; readonly fresh: boolean } { + const scriptId = cdpScriptId(route.script.scriptKey) + const current = this.routes.get(scriptId) + if (current !== undefined && current.realm !== route.realm) { + throw new Error(`Inspector realms produced the same script key ${scriptId}`) + } + this.routes.set(scriptId, route) + return { scriptId, fresh: current === undefined } + } + + /** + * Resolve an active CDP ScriptId. + * @param scriptId - Connection-visible script id. + * @returns The active route when the script remains connected. + */ + resolve(scriptId: string): DebuggerScriptRoute | undefined { + return this.routes.get(cdpStringId<'CdpScriptId'>(scriptId, 'scriptId')) + } + + /** + * Resolve a script by its exact URL. + * @param url - Script URL from a CDP request. + * @returns The active route when one script has that URL. + */ + byUrl(url: string): DebuggerScriptRoute | undefined { + for (const route of this.routes.values()) { + if (route.script.url === url) return route + } + return undefined + } + + /** + * Resolve a script by its exact content hash. + * @param hash - Script hash from a breakpoint request. + * @returns The active route when one script has that hash. + */ + byHash(hash: string): DebuggerScriptRoute | undefined { + for (const route of this.routes.values()) { + if (route.script.hash === hash) return route + } + return undefined + } + + /** + * Resolve the first script whose URL matches a breakpoint regular expression. + * @param pattern - JavaScript regular-expression source accepted by CDP. + * @returns The first matching active route. + */ + byUrlPattern(pattern: string): DebuggerScriptRoute | undefined { + const expression = new RegExp(pattern, 'u') + for (const route of this.routes.values()) { + if (expression.test(route.script.url)) return route + } + return undefined + } + + /** + * Test whether a disconnected script belonged to a realm without active debugging. + * @param scriptId - Script id from a later CDP request. + * @returns Whether the id must still fail as an unsupported Client script. + */ + wasUnsupported(scriptId: string): boolean { + return this.retiredUnsupported.has(cdpStringId<'CdpScriptId'>(scriptId, 'scriptId')) + } + + /** + * Forget scripts for one closed realm while retaining their unsupported identity. + * @param realm - Realm session being removed. + */ + removeRealm(realm: InspectorRealmSession): void { + for (const [scriptId, route] of this.routes) { + if (route.realm !== realm) continue + this.routes.delete(scriptId) + if (realm.debugger.state === 'unsupported') this.retiredUnsupported.add(scriptId) + } + } + + /** Forget all active and retired script routes. */ + clear(): void { + this.routes.clear() + this.retiredUnsupported.clear() + } +} + +/** + * Preserve a branded script key as its CDP wire identifier. + * @param scriptKey - Realm-wide Runtime script key. + * @returns The corresponding CDP ScriptId text. + */ +export function cdpScriptId(scriptKey: RuntimeScriptKey): CdpScriptId { + return cdpStringId<'CdpScriptId'>(scriptKey, 'scriptId') +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts new file mode 100644 index 0000000000..c27ff59325 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/debugger/session.ts @@ -0,0 +1,350 @@ +/** Per-DevTools Debugger and source routing across Host and Client realms. */ + +import { respondToCdpRequest, sendCdpFailure, type CdpRequest, type CdpTransport } from '../../protocol.ts' +import type { + DebuggerBackend, + NativeDomainBackend, + SourceBackend, +} from '../../../../shared/cdp/realm.ts' +import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts' +import type { InspectorRealmSession } from '../../../inspection/realm.ts' +import type { InspectorRealmSessionEvent, InspectorRealmSessionSet } from '../../realm-sessions.ts' +import type { + RuntimeDebuggerEnableRequest, + RuntimeDebuggerEvent, + RuntimeScript, +} from '../../../../shared/cdp/index.ts' +import { exactKeys, optionalBoolean } from '../../../../shared/validation.ts' +import type { RuntimeDomainSession } from '../runtime/index.ts' +import { parseCallFrameEvaluation, requestScriptId } from './cdp-params.ts' +import { debuggerEvent, scriptParsedEvent } from './projector.ts' +import { DebuggerScriptRegistry } from './script-registry.ts' + +/** Owns Debugger lifecycle, shared script projection, and Host-native fallback. */ +export class DebuggerDomainSession { + private readonly scripts = new DebuggerScriptRegistry() + private readonly sourceDisposers = new Map void>() + private readonly debuggerDisposers = new Map void>() + private readonly callFrameRealms = new Map() + private readonly unsubscribeRealms: () => void + private readonly native: NativeDomainBackend + private debuggerEnableRequest: RuntimeDebuggerEnableRequest = {} + private enabled = false + private closed = false + + constructor( + private readonly transport: CdpTransport, + private readonly realms: InspectorRealmSessionSet, + private readonly runtime: RuntimeDomainSession, + ) { + const native = realms.all() + .map(realm => realm.nativeDomains) + .find(capability => capability.state === 'supported') + if (native === undefined) throw new Error('Inspector has no native Host debugger transport') + this.native = native.backend + this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) }) + } + + /** + * Handle one Debugger request, including Client read-only source operations. + * @param request - Parsed CDP request. + * @returns Whether the method belongs to the Debugger domain. + */ + handle(request: CdpRequest): boolean { + if (!request.method.startsWith('Debugger.')) return false + switch (request.method) { + case 'Debugger.enable': + this.respond(request, () => this.enable(request.params)) + return true + case 'Debugger.disable': + exactKeys(request.params, [], 'Debugger.disable parameters') + this.respond(request, () => this.disable()) + return true + case 'Debugger.getScriptSource': + this.respond(request, () => this.getScriptSource(request.params)) + return true + case 'Debugger.searchInContent': + this.respond(request, () => this.searchInContent(request.params)) + return true + case 'Debugger.evaluateOnCallFrame': + this.respond(request, () => this.evaluateOnCallFrame(request.params)) + return true + case 'Debugger.pause': + exactKeys(request.params, [], 'Debugger.pause parameters') + this.respond(request, () => this.pause()) + return true + case 'Debugger.resume': + this.respond(request, () => this.resume(request.params)) + return true + default: + this.forwardNative(request) + return true + } + } + + /** Release source and debugger subscriptions. */ + close(): void { + if (this.closed) return + this.closed = true + this.unsubscribeRealms() + this.detachCapabilities() + this.callFrameRealms.clear() + this.scripts.clear() + this.runtime.releaseProjectedGroup('backtrace') + } + + private async enable(params: Readonly>): Promise>> { + exactKeys(params, ['maxScriptsCacheSize'], 'Debugger.enable parameters') + if (this.enabled) return {} + const maxScriptsCacheSize = params.maxScriptsCacheSize + if (maxScriptsCacheSize !== undefined + && (typeof maxScriptsCacheSize !== 'number' || !Number.isFinite(maxScriptsCacheSize) || maxScriptsCacheSize < 0)) { + throw new Error('Debugger.enable maxScriptsCacheSize must be a non-negative number') + } + const enableRequest = maxScriptsCacheSize === undefined ? {} : { maxScriptsCacheSize } + this.debuggerEnableRequest = enableRequest + this.enabled = true + try { + for (const realm of this.realms.all()) this.attachCapabilities(realm) + const results = await Promise.all(this.realms.all().map(async realm => + realm.debugger.state === 'supported' ? realm.debugger.backend.enable(enableRequest) : {})) + await Promise.all(this.realms.all().map(async realm => this.publishCatalog(realm))) + return mergeResults(results) + } catch (error) { + this.enabled = false + this.debuggerEnableRequest = {} + this.detachCapabilities() + this.scripts.clear() + await Promise.allSettled(this.realms.all().map(async (realm) => { + if (realm.debugger.state === 'supported') await realm.debugger.backend.disable() + })) + throw error + } + } + + private async disable(): Promise>> { + this.enabled = false + this.debuggerEnableRequest = {} + this.detachCapabilities() + this.callFrameRealms.clear() + this.scripts.clear() + this.runtime.releaseProjectedGroup('backtrace') + const results = await Promise.all(this.realms.all().map(async realm => + realm.debugger.state === 'supported' ? realm.debugger.backend.disable() : {})) + return mergeResults(results) + } + + private async getScriptSource(params: Readonly>): Promise { + exactKeys(params, ['scriptId'], 'Debugger.getScriptSource parameters') + if (typeof params.scriptId !== 'string') throw new Error('Debugger.getScriptSource requires scriptId') + const route = this.scripts.resolve(params.scriptId) + if (route !== undefined) return { scriptSource: await route.source.getScriptSource(route.script.scriptKey) } + if (this.scripts.wasUnsupported(params.scriptId) || params.scriptId.startsWith('client:')) { + throw new Error('Client script is no longer available') + } + return this.native.request('Debugger.getScriptSource', params) + } + + private async searchInContent(params: Readonly>): Promise { + exactKeys(params, ['scriptId', 'query', 'caseSensitive', 'isRegex'], 'Debugger.searchInContent parameters') + if (typeof params.scriptId !== 'string' || typeof params.query !== 'string') { + throw new Error('Debugger.searchInContent requires scriptId and query') + } + if (params.caseSensitive !== undefined && typeof params.caseSensitive !== 'boolean') { + throw new Error('Debugger.searchInContent caseSensitive must be a boolean') + } + if (params.isRegex !== undefined && typeof params.isRegex !== 'boolean') { + throw new Error('Debugger.searchInContent isRegex must be a boolean') + } + const route = this.scripts.resolve(params.scriptId) + if (route === undefined) { + if (this.scripts.wasUnsupported(params.scriptId) || params.scriptId.startsWith('client:')) { + throw new Error('Client script is no longer available') + } + return this.native.request('Debugger.searchInContent', params) + } + const source = await route.source.getScriptSource(route.script.scriptKey) + return { + result: searchLines( + source, + params.query, + params.caseSensitive === true, + params.isRegex === true, + ), + } + } + + private async evaluateOnCallFrame(params: Readonly>): Promise { + const parsed = parseCallFrameEvaluation(params) + if (parsed.callFrameId.startsWith('client:')) throw new Error('Client native debugging is unavailable') + const realm = this.callFrameRealms.get(parsed.callFrameId) ?? this.supportedDebugger() + const objectGroup = parsed.objectGroup ?? 'backtrace' + const completion = await debuggerBackend(realm).evaluateOnCallFrame({ ...parsed, objectGroup }) + return this.runtime.projectCompletion(realm, completion, objectGroup) + } + + private async pause(): Promise { + const supported = this.realms.all().filter(realm => realm.debugger.state === 'supported') + if (supported.length === 0) throw new Error('Debugger.pause is unsupported by every active realm') + const results = await Promise.all(supported.map(async realm => debuggerBackend(realm).pause())) + return mergeResults(results) + } + + private async resume(params: Readonly>): Promise { + exactKeys(params, ['terminateOnResume'], 'Debugger.resume parameters') + const request = optionalBoolean(params, 'terminateOnResume') + const supported = this.realms.all().filter(realm => realm.debugger.state === 'supported') + if (supported.length === 0) throw new Error('Debugger.resume is unsupported by every active realm') + const results = await Promise.all(supported.map(async realm => debuggerBackend(realm).resume(request))) + return mergeResults(results) + } + + private forwardNative(request: CdpRequest): void { + let params: Readonly> + try { + const unsupported = this.unsupportedRoute(request.params) + if (unsupported !== undefined) throw new Error(unsupported) + params = this.runtime.nativeParameters(request.params) + } catch (error) { + sendCdpFailure(this.transport, request, error) + return + } + respondToCdpRequest(this.transport, request, async () => this.native.request(request.method, params)) + } + + private unsupportedRoute(params: Readonly>): string | undefined { + const scriptId = requestScriptId(params) + if (scriptId !== undefined) { + const route = this.scripts.resolve(scriptId) + if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason + if (route === undefined && this.scripts.wasUnsupported(scriptId)) return 'Client script is no longer available' + } + if (typeof params.url === 'string') { + const route = this.scripts.byUrl(params.url) + if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason + } + if (typeof params.urlRegex === 'string') { + const route = this.scripts.byUrlPattern(params.urlRegex) + if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason + } + if (typeof params.scriptHash === 'string') { + const route = this.scripts.byHash(params.scriptHash) + if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason + } + if (typeof params.objectId === 'string') { + const route = this.runtime.objectRoute(params.objectId) + if (route?.realm.debugger.state === 'unsupported') return route.realm.debugger.reason + } + return undefined + } + + private receiveRealm(event: InspectorRealmSessionEvent): void { + if (event.type === 'opened') { + if (this.enabled) void this.enableRealm(event.session).catch((error: unknown) => { + console.error(`Inspector could not enable Debugger realm ${event.session.descriptor.label}:`, error) + }) + return + } + this.sourceDisposers.get(event.session.descriptor.realmId)?.() + this.sourceDisposers.delete(event.session.descriptor.realmId) + this.debuggerDisposers.get(event.session.descriptor.realmId)?.() + this.debuggerDisposers.delete(event.session.descriptor.realmId) + for (const [callFrameId, realm] of this.callFrameRealms) { + if (realm === event.session) this.callFrameRealms.delete(callFrameId) + } + this.scripts.removeRealm(event.session) + } + + private async enableRealm(realm: InspectorRealmSession): Promise { + this.attachCapabilities(realm) + if (realm.debugger.state === 'supported') await realm.debugger.backend.enable(this.debuggerEnableRequest) + await this.publishCatalog(realm) + } + + private attachCapabilities(realm: InspectorRealmSession): void { + if (realm.sources.state === 'supported' && !this.sourceDisposers.has(realm.descriptor.realmId)) { + const source = realm.sources.backend + this.sourceDisposers.set(realm.descriptor.realmId, source.subscribe((script) => { + if (this.enabled) this.publishScript(realm, source, script) + })) + } + if (realm.debugger.state === 'supported' && !this.debuggerDisposers.has(realm.descriptor.realmId)) { + this.debuggerDisposers.set(realm.descriptor.realmId, realm.debugger.backend.subscribe((event) => { + if (this.enabled) this.publishDebuggerEvent(realm, event) + })) + } + } + + private async publishCatalog(realm: InspectorRealmSession): Promise { + if (!this.enabled || realm.sources.state === 'unsupported') return + const scripts = await realm.sources.backend.listScripts() + for (const script of scripts) this.publishScript(realm, realm.sources.backend, script) + } + + private publishScript(realm: InspectorRealmSession, source: SourceBackend, script: RuntimeScript): void { + const registered = this.scripts.register({ realm, source, script }) + if (registered.fresh) this.transport.send(scriptParsedEvent(realm, script)) + } + + private publishDebuggerEvent( + realm: InspectorRealmSession, + event: RuntimeDebuggerEvent, + ): void { + if (event.type === 'paused') { + for (const frame of event.callFrames) this.callFrameRealms.set(frame.callFrameId, realm) + } else if (event.type === 'resumed') { + for (const [callFrameId, owner] of this.callFrameRealms) { + if (owner === realm) this.callFrameRealms.delete(callFrameId) + } + this.runtime.releaseProjectedGroup('backtrace') + } + this.transport.send(debuggerEvent(realm, event, this.runtime)) + } + + private supportedDebugger(): InspectorRealmSession { + const realm = this.realms.all().find(candidate => candidate.debugger.state === 'supported') + if (realm === undefined) throw new Error('No active realm supports call-frame evaluation') + return realm + } + + private detachCapabilities(): void { + for (const dispose of this.sourceDisposers.values()) dispose() + this.sourceDisposers.clear() + for (const dispose of this.debuggerDisposers.values()) dispose() + this.debuggerDisposers.clear() + } + + private respond(request: CdpRequest, operation: () => Promise): void { + respondToCdpRequest(this.transport, request, operation) + } +} + +function debuggerBackend(realm: InspectorRealmSession): DebuggerBackend { + if (realm.debugger.state === 'unsupported') throw new Error(realm.debugger.reason) + return realm.debugger.backend +} + +function mergeResults(results: readonly Readonly>[]): Readonly> { + const merged: Record = {} + for (const result of results) Object.assign(merged, result) + return merged +} + +function searchLines( + source: string, + query: string, + caseSensitive: boolean, + isRegex: boolean, +): ReadonlyArray<{ readonly lineNumber: number; readonly lineContent: string }> { + const expression = isRegex + ? new RegExp(query, caseSensitive ? 'u' : 'iu') + : undefined + const expected = caseSensitive ? query : query.toLowerCase() + const result: Array<{ readonly lineNumber: number; readonly lineContent: string }> = [] + for (const [lineNumber, lineContent] of source.split('\n').entries()) { + const matches = expression?.test(lineContent) + ?? (caseSensitive ? lineContent : lineContent.toLowerCase()).includes(expected) + if (matches) result.push({ lineNumber, lineContent }) + } + return result +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/native.ts b/packages/experimental/inspector/src/worker/cdp/domains/native.ts new file mode 100644 index 0000000000..dda9bdd8d5 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/native.ts @@ -0,0 +1,49 @@ +/** Explicit adapter for Host-only native CDP methods during realm migration. */ + +import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../protocol.ts' +import type { NativeDomainBackend } from '../../../shared/cdp/realm.ts' + +/** Forwards one explicit Host-native domain through a transport-neutral Node session. */ +export class HostNativeDomainSession { + private readonly unsubscribe: () => void + + constructor( + private readonly transport: CdpTransport, + private readonly target: NativeDomainBackend, + ) { + this.unsubscribe = target.subscribe((message) => { + if (!this.owns(message.method) + || message.method === 'Runtime.consoleAPICalled' + || message.method === 'Runtime.exceptionThrown') return + this.transport.send(message) + }) + } + + /** + * Execute one Host-native CDP request and send its correlated result. + * @param request - Parsed request owned by a native Host domain. + * @returns Whether this adapter owns the request's domain. + */ + handle(request: CdpRequest): boolean { + if (!this.owns(request.method)) return false + respondToCdpRequest(this.transport, request, async () => this.target.request(request.method, request.params)) + return true + } + + /** + * Test whether this adapter owns a CDP method. + * @param method - CDP method name. + * @returns Whether the method belongs to an explicit Host-native domain. + */ + owns(method: string): boolean { + return NATIVE_DOMAINS.has(method.slice(0, method.indexOf('.'))) + } + + /** Stop forwarding native notifications to this DevTools connection. */ + close(): void { + this.unsubscribe() + } + +} + +const NATIVE_DOMAINS = new Set(['Runtime', 'Profiler', 'HeapProfiler', 'Schema']) diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts new file mode 100644 index 0000000000..b38c40b334 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts @@ -0,0 +1,256 @@ +/** Validation and normalization of CDP Runtime parameters routed to a Client realm. */ + +import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts' +import { isJsonValue, isPlainObject, type InspectorJsonValue } from '../../../../shared/json.ts' +import type { + RuntimeAwaitPromiseRequest, + RuntimeCallFunctionRequest, + RuntimeEvaluateRequest, + RuntimeGetPropertiesRequest, +} from '../../../../shared/cdp/index.ts' +import { exactKeys, optionalBoolean, optionalString } from '../../../../shared/validation.ts' + +/** Numeric or globally unique selector for one execution context. */ +export interface CdpExecutionContextSelector { + readonly contextId?: number + readonly executionContextId?: number + readonly uniqueContextId?: string +} + +/** Validated Runtime.evaluate parameters and their routing selector. */ +export interface ParsedEvaluate extends CdpExecutionContextSelector { + readonly request: RuntimeEvaluateRequest +} + +/** Client-independent call argument before object ids are routed. */ +export type CdpCallArgument = + | { readonly kind: 'value'; readonly value: InspectorJsonValue } + | { readonly kind: 'unserializable'; readonly value: string } + | { readonly kind: 'object'; readonly objectId: string } + | { readonly kind: 'undefined' } + +/** Validated Runtime.callFunctionOn parameters before object-id routing. */ +export interface ParsedCallFunction extends CdpExecutionContextSelector { + readonly objectId?: string + readonly arguments: readonly CdpCallArgument[] + readonly request: Omit, 'receiver' | 'arguments'> +} + +/** + * Parse realm-routed `Runtime.evaluate` parameters. + * @param params - Untrusted CDP parameters. + * @returns A context selector and normalized Runtime request. + */ +export function parseEvaluate(params: Readonly>): ParsedEvaluate { + exactKeys(params, [ + 'expression', 'objectGroup', 'includeCommandLineAPI', 'silent', 'contextId', 'returnByValue', + 'generatePreview', 'userGesture', 'awaitPromise', 'throwOnSideEffect', 'timeout', 'disableBreaks', + 'replMode', 'allowUnsafeEvalBlockedByCSP', 'uniqueContextId', 'serializationOptions', + ], 'Runtime.evaluate params') + if (typeof params.expression !== 'string') throw new Error('Runtime.evaluate expression must be a string') + const selector = parseContextSelector(params, 'contextId') + const timeout = params.timeout + if (timeout !== undefined && (typeof timeout !== 'number' || !Number.isFinite(timeout) || timeout < 0)) { + throw new Error('Runtime.evaluate timeout must be a non-negative finite number') + } + return { + ...selector, + request: { + expression: params.expression, + ...optionalString(params, 'objectGroup'), + ...optionalBoolean(params, 'includeCommandLineAPI'), + ...optionalBoolean(params, 'silent'), + ...optionalBoolean(params, 'returnByValue'), + ...optionalBoolean(params, 'generatePreview'), + ...optionalBoolean(params, 'userGesture'), + ...optionalBoolean(params, 'awaitPromise'), + ...optionalBoolean(params, 'disableBreaks'), + ...optionalBoolean(params, 'replMode'), + ...optionalBoolean(params, 'allowUnsafeEvalBlockedByCSP'), + ...optionalBoolean(params, 'throwOnSideEffect'), + ...optionalJsonObject(params, 'serializationOptions'), + ...(timeout === undefined ? {} : { timeoutMs: timeout }), + }, + } +} + +/** + * Parse realm-routed `Runtime.getProperties` parameters. + * @param params - Untrusted CDP parameters. + * @returns The external object id and handle-free Runtime request. + */ +export function parseGetProperties( + params: Readonly>, +): { + readonly objectId: string + readonly request: Omit, 'handle'> +} { + exactKeys(params, [ + 'objectId', 'ownProperties', 'accessorPropertiesOnly', 'generatePreview', 'nonIndexedPropertiesOnly', + ], 'Runtime.getProperties params') + if (typeof params.objectId !== 'string') throw new Error('Runtime.getProperties objectId must be a string') + return { + objectId: params.objectId, + request: { + ...optionalBoolean(params, 'ownProperties'), + ...optionalBoolean(params, 'accessorPropertiesOnly'), + ...optionalBoolean(params, 'generatePreview'), + ...optionalBoolean(params, 'nonIndexedPropertiesOnly'), + }, + } +} + +/** + * Parse Client-routed `Runtime.callFunctionOn` parameters. + * @param params - Untrusted CDP parameters. + * @returns Routing fields, arguments, and a handle-free Runtime request. + */ +export function parseCallFunction(params: Readonly>): ParsedCallFunction { + exactKeys(params, [ + 'functionDeclaration', 'objectId', 'arguments', 'silent', 'returnByValue', 'generatePreview', 'userGesture', + 'awaitPromise', 'executionContextId', 'objectGroup', 'throwOnSideEffect', 'uniqueContextId', 'serializationOptions', + ], 'Runtime.callFunctionOn params') + if (typeof params.functionDeclaration !== 'string') { + throw new Error('Runtime.callFunctionOn functionDeclaration must be a string') + } + const selector = parseContextSelector(params, 'executionContextId') + const objectId = optionalObjectId(params.objectId, 'Runtime.callFunctionOn objectId') + if (objectId === undefined + && selector.executionContextId === undefined + && selector.uniqueContextId === undefined) { + throw new Error('Runtime.callFunctionOn requires objectId or an execution context') + } + if (objectId !== undefined && (selector.executionContextId !== undefined || selector.uniqueContextId !== undefined)) { + throw new Error('Runtime.callFunctionOn objectId and execution context are mutually exclusive') + } + let args: readonly CdpCallArgument[] = [] + if (params.arguments !== undefined) { + if (!Array.isArray(params.arguments)) throw new Error('Runtime.callFunctionOn arguments must be an array') + args = params.arguments.map(parseCallArgument) + } + return { + ...selector, + ...(objectId === undefined ? {} : { objectId }), + arguments: args, + request: { + functionDeclaration: params.functionDeclaration, + ...optionalString(params, 'objectGroup'), + ...optionalBoolean(params, 'silent'), + ...optionalBoolean(params, 'returnByValue'), + ...optionalBoolean(params, 'generatePreview'), + ...optionalBoolean(params, 'userGesture'), + ...optionalBoolean(params, 'awaitPromise'), + ...optionalBoolean(params, 'throwOnSideEffect'), + ...optionalJsonObject(params, 'serializationOptions'), + }, + } +} + +/** + * Parse Client-routed `Runtime.awaitPromise` parameters. + * @param params - Untrusted CDP parameters. + * @returns The external promise id and handle-free Runtime request. + */ +export function parseAwaitPromise(params: Readonly>): { + readonly promiseObjectId: string + readonly request: Omit, 'promise'> +} { + exactKeys(params, ['promiseObjectId', 'returnByValue', 'generatePreview'], 'Runtime.awaitPromise params') + if (typeof params.promiseObjectId !== 'string') throw new Error('Runtime.awaitPromise promiseObjectId must be a string') + return { + promiseObjectId: params.promiseObjectId, + request: { + ...optionalBoolean(params, 'returnByValue'), + ...optionalBoolean(params, 'generatePreview'), + }, + } +} + +/** + * Parse one required object id. + * @param params - Untrusted CDP parameters. + * @returns The object id. + */ +export function parseReleaseObject(params: Readonly>): string { + exactKeys(params, ['objectId'], 'Runtime.releaseObject params') + if (typeof params.objectId !== 'string') throw new Error('Runtime.releaseObject objectId must be a string') + return params.objectId +} + +/** + * Parse one required object-group name. + * @param params - Untrusted CDP parameters. + * @returns The object-group name. + */ +export function parseReleaseObjectGroup(params: Readonly>): string { + exactKeys(params, ['objectGroup'], 'Runtime.releaseObjectGroup params') + if (typeof params.objectGroup !== 'string') throw new Error('Runtime.releaseObjectGroup objectGroup must be a string') + return params.objectGroup +} + +/** + * Parse `Runtime.globalLexicalScopeNames` context selection. + * @param params - Untrusted CDP parameters. + * @returns The validated context selector. + */ +export function parseGlobalLexicalScopeNames(params: Readonly>): CdpExecutionContextSelector { + exactKeys(params, ['executionContextId', 'uniqueContextId'], 'Runtime.globalLexicalScopeNames params') + return parseContextSelector(params, 'executionContextId') +} + +function parseCallArgument(value: unknown): CdpCallArgument { + if (!isPlainObject(value)) throw new Error('Runtime.callFunctionOn argument must be an object') + exactKeys(value, ['value', 'unserializableValue', 'objectId'], 'Runtime.callFunctionOn argument') + const present = ['value', 'unserializableValue', 'objectId'].filter(key => Object.hasOwn(value, key)) + if (present.length > 1) throw new Error('Runtime.callFunctionOn argument has multiple value representations') + if (present.length === 0) return { kind: 'undefined' } + if (present[0] === 'value') { + if (!isJsonValue(value.value)) throw new Error('Runtime.callFunctionOn argument value must be JSON') + return { kind: 'value', value: value.value } + } + if (present[0] === 'unserializableValue') { + if (typeof value.unserializableValue !== 'string') { + throw new Error('Runtime.callFunctionOn unserializableValue must be a string') + } + return { kind: 'unserializable', value: value.unserializableValue } + } + if (typeof value.objectId !== 'string') throw new Error('Runtime.callFunctionOn argument objectId must be a string') + return { kind: 'object', objectId: value.objectId } +} + +function parseContextSelector( + params: Readonly>, + numericKey: 'contextId' | 'executionContextId', +): CdpExecutionContextSelector { + const numeric = params[numericKey] + const unique = params.uniqueContextId + if (numeric !== undefined && (!Number.isSafeInteger(numeric))) { + throw new Error(`Runtime ${numericKey} must be an integer`) + } + if (unique !== undefined && typeof unique !== 'string') throw new Error('Runtime uniqueContextId must be a string') + if (numeric !== undefined && unique !== undefined) throw new Error('Runtime context selectors are mutually exclusive') + return { + ...(numeric === undefined + ? {} + : numericKey === 'contextId' + ? { contextId: numeric as number } + : { executionContextId: numeric as number }), + ...(unique === undefined ? {} : { uniqueContextId: unique }), + } +} + +function optionalObjectId(value: unknown, label: string): string | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string') throw new Error(`${label} must be a string`) + return value +} + +function optionalJsonObject( + value: Readonly>, + key: Key, +): Partial>>> { + const item = value[key] + if (item === undefined) return {} + if (!isPlainObject(item) || !isJsonValue(item)) throw new Error(`Runtime ${key} must be a JSON object`) + return { [key]: item } as Partial>>> +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts new file mode 100644 index 0000000000..f6a1d888e9 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/index.ts @@ -0,0 +1,3 @@ +/** Client-aware Runtime domain exports. */ + +export { RuntimeDomainSession } from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts new file mode 100644 index 0000000000..d620af28f2 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/object-table.ts @@ -0,0 +1,323 @@ +/** Per-CDP-connection routing and projection for every realm's Runtime objects. */ + +import type { + RuntimeCompletion, + RuntimeConsoleBackendEvent, + RuntimeExceptionDetails, + RuntimeInternalPropertyDescriptor, + RuntimePrivatePropertyDescriptor, + RuntimeProperties, + RuntimePropertyDescriptor, + RuntimeRemoteObject, + RuntimeStackTrace, +} from '../../../../shared/cdp/index.ts' +import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts' +import type { RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts' +import type { InspectorRealmDescriptor, InspectorRealmSession } from '../../../inspection/realm.ts' +import { cdpStringId, type CdpRemoteObjectId, type InspectorConnectionId } from '../../ids.ts' + +/** Object retained behind one connection-local CDP object id. */ +export interface RuntimeObjectRoute { + readonly realm: InspectorRealmSession + readonly handle: RuntimeBackendObjectHandle + readonly group: string | undefined +} + +/** Semantic presentation applied when an object belongs to a projected node. */ +export interface RuntimeObjectPresentation { + readonly subtype: 'node' + readonly className: string + readonly description: string +} + +/** Observer of newly exposed Runtime object ids. */ +export type RuntimeObjectObserver = ( + objectId: CdpRemoteObjectId, + realm: InspectorRealmDescriptor, + reference: InspectorObjectReference, + group: string | undefined, +) => RuntimeObjectPresentation | undefined + +/** CDP Runtime payload derived from one realm completion. */ +export interface CdpRuntimeCompletion { + readonly result: Readonly> + readonly exceptionDetails?: Readonly> +} + +/** CDP Runtime payload derived from one realm's property descriptors. */ +export interface CdpGetPropertiesResult { + readonly result: readonly Readonly>[] + readonly internalProperties?: readonly Readonly>[] + readonly privateProperties?: readonly Readonly>[] + readonly exceptionDetails?: Readonly> +} + +/** One CDP notification projected from a realm Console event. */ +export interface CdpRuntimeEvent { + readonly method: 'Runtime.consoleAPICalled' | 'Runtime.exceptionThrown' + readonly params: Readonly> +} + +/** Maps every realm's backend handles to object ids scoped to one CDP connection. */ +export class RuntimeObjectTable { + private readonly routes = new Map() + private nextObjectId = 1 + private nextExceptionId = 1 + private observer: RuntimeObjectObserver | undefined + + constructor(private readonly connectionId: InspectorConnectionId) {} + + /** + * Install Cordis object recognition after Runtime and DOM sessions are assembled. + * @param observer - Callback mapping a semantic reference to node presentation. + */ + setObserver(observer: RuntimeObjectObserver): void { + this.observer = observer + } + + /** + * Resolve one connection-local object id. + * @param objectId - CDP object id allocated by this table. + * @returns Its realm and backend handle when current. + */ + resolve(objectId: string): RuntimeObjectRoute | undefined { + return this.routes.get(cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId')) + } + + /** + * Convert a realm completion to CDP fields. + * @param realm - Realm session that produced the value. + * @param value - Engine-independent completion. + * @param group - Object group inherited by exposed handles. + * @returns CDP Runtime completion fields. + */ + completion( + realm: InspectorRealmSession, + value: RuntimeCompletion, + group: string | undefined, + ): CdpRuntimeCompletion { + return { + result: this.remote(realm, value.result, group), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: this.exception(realm, value.exceptionDetails, group) }), + } + } + + /** + * Convert realm property descriptors to CDP fields. + * @param realm - Realm session that owns returned object references. + * @param value - Engine-independent property result. + * @param group - Object group inherited from the inspected object. + * @returns CDP Runtime property result fields. + */ + properties( + realm: InspectorRealmSession, + value: RuntimeProperties, + group: string | undefined, + ): CdpGetPropertiesResult { + return { + result: value.properties.map(property => this.property(realm, property, group)), + ...(value.internalProperties === undefined + ? {} + : { internalProperties: value.internalProperties.map(property => this.internalProperty(realm, property, group)) }), + ...(value.privateProperties === undefined + ? {} + : { privateProperties: value.privateProperties.map(property => this.privateProperty(realm, property, group)) }), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: this.exception(realm, value.exceptionDetails, group) }), + } + } + + /** + * Project one realm Console event to a CDP Runtime notification. + * @param realm - Realm session that emitted the event. + * @param value - Realm-neutral Console or exception event. + * @returns CDP method and parameters. + */ + consoleEvent( + realm: InspectorRealmSession, + value: RuntimeConsoleBackendEvent, + ): CdpRuntimeEvent { + if (value.type === 'console-api') { + const contextId = value.event.contextId + ?? (realm.context.kind === 'synthetic' ? realm.context.id : undefined) + return { + method: 'Runtime.consoleAPICalled', + params: { + type: value.event.type, + args: value.event.arguments.map(argument => this.remote(realm, argument, 'console')), + timestamp: value.event.timestamp, + ...(contextId === undefined ? {} : { executionContextId: contextId }), + ...(value.event.stackTrace === undefined ? {} : { stackTrace: cdpStackTrace(value.event.stackTrace) }), + }, + } + } + const contextId = value.event.contextId + ?? (realm.context.kind === 'synthetic' ? realm.context.id : undefined) + return { + method: 'Runtime.exceptionThrown', + params: { + timestamp: value.event.timestamp, + exceptionDetails: { + ...this.exception(realm, value.event.details, 'console'), + ...(contextId === undefined ? {} : { executionContextId: contextId }), + }, + }, + } + } + + /** + * List realm sessions retaining at least one object in a group. + * @param group - DevTools object-group name. + * @returns Distinct realm sessions that must receive the release. + */ + realmsInGroup(group: string): InspectorRealmSession[] { + const realms = new Set() + for (const route of this.routes.values()) { + if (route.group === group) realms.add(route.realm) + } + return [...realms] + } + + /** + * Forget one externally visible object id. + * @param objectId - Released CDP object id. + */ + release(objectId: string): void { + this.routes.delete(cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId')) + } + + /** + * Forget all ids retained under one object group. + * @param group - Released object-group name. + */ + releaseGroup(group: string): void { + for (const [objectId, route] of this.routes) { + if (route.group === group) this.routes.delete(objectId) + } + } + + /** + * Forget every object owned by one closed realm session. + * @param realm - Closed realm session. + */ + releaseRealm(realm: InspectorRealmSession): void { + for (const [objectId, route] of this.routes) { + if (route.realm === realm) this.routes.delete(objectId) + } + } + + /** Forget every object exposed on this DevTools connection. */ + clear(): void { + this.routes.clear() + } + + /** + * Project one common Runtime value and retain its backend handle for this connection. + * @param realm - Realm session that owns the value. + * @param value - Realm-neutral Runtime value. + * @param group - Object group assigned to any exposed handle. + * @returns CDP RemoteObject fields. + */ + remote( + realm: InspectorRealmSession, + value: RuntimeRemoteObject, + group: string | undefined, + ): Readonly> { + const objectId = value.object === undefined + ? undefined + : this.expose(realm, value.object.handle, group) + const presentation = objectId === undefined || value.semanticReference === undefined + ? undefined + : this.observer?.(objectId, realm.descriptor, value.semanticReference, group) + const descriptor = value.descriptor + return { + ...descriptor, + ...(presentation?.subtype === undefined ? {} : { subtype: presentation.subtype }), + ...(presentation?.className === undefined ? {} : { className: presentation.className }), + ...(presentation?.description === undefined ? {} : { description: presentation.description }), + ...(objectId === undefined ? {} : { objectId }), + } + } + + private property( + realm: InspectorRealmSession, + property: RuntimePropertyDescriptor, + group: string | undefined, + ): Readonly> { + return { + ...property, + ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }), + ...(property.get === undefined ? {} : { get: this.remote(realm, property.get, group) }), + ...(property.set === undefined ? {} : { set: this.remote(realm, property.set, group) }), + ...(property.symbol === undefined ? {} : { symbol: this.remote(realm, property.symbol, group) }), + } + } + + private internalProperty( + realm: InspectorRealmSession, + property: RuntimeInternalPropertyDescriptor, + group: string | undefined, + ): Readonly> { + return { + name: property.name, + ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }), + } + } + + private privateProperty( + realm: InspectorRealmSession, + property: RuntimePrivatePropertyDescriptor, + group: string | undefined, + ): Readonly> { + return { + name: property.name, + ...(property.value === undefined ? {} : { value: this.remote(realm, property.value, group) }), + ...(property.get === undefined ? {} : { get: this.remote(realm, property.get, group) }), + ...(property.set === undefined ? {} : { set: this.remote(realm, property.set, group) }), + } + } + + private exception( + realm: InspectorRealmSession, + details: RuntimeExceptionDetails, + group: string | undefined, + ): Readonly> { + return { + ...details, + exceptionId: this.nextExceptionId++, + ...(realm.context.kind === 'synthetic' ? { executionContextId: realm.context.id } : {}), + ...(details.stackTrace === undefined ? {} : { stackTrace: cdpStackTrace(details.stackTrace) }), + ...(details.exception === undefined ? {} : { exception: this.remote(realm, details.exception, group) }), + } + } + + private expose( + realm: InspectorRealmSession, + handle: RuntimeBackendObjectHandle, + group: string | undefined, + ): CdpRemoteObjectId { + const objectId = cdpStringId<'CdpRemoteObjectId'>( + `runtime:${this.connectionId}:${String(this.nextObjectId++)}`, + 'objectId', + ) + this.routes.set(objectId, { realm, handle, group }) + return objectId + } +} + +function cdpStackTrace(stack: RuntimeStackTrace): Readonly> { + return { + ...(stack.description === undefined ? {} : { description: stack.description }), + callFrames: stack.callFrames.map(frame => ({ + functionName: frame.functionName, + scriptId: frame.scriptKey ?? '0', + url: frame.url, + lineNumber: frame.lineNumber, + columnNumber: frame.columnNumber, + })), + ...(stack.parent === undefined ? {} : { parent: cdpStackTrace(stack.parent) }), + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts new file mode 100644 index 0000000000..9b259ab7fb --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts @@ -0,0 +1,455 @@ +/** Per-DevTools-session Runtime routing across uniform Host and Client realms. */ + +import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts' +import type { InspectorRealmId, RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts' +import type { RuntimeCallArgument, RuntimeCompletion, RuntimeRemoteObject } from '../../../../shared/cdp/index.ts' +import type { RuntimeBackend } from '../../../../shared/cdp/realm.ts' +import { cdpError, respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts' +import type { InspectorRealmSession } from '../../../inspection/realm.ts' +import type { InspectorRealmSessionEvent, InspectorRealmSessionSet } from '../../realm-sessions.ts' +import { + parseAwaitPromise, + parseCallFunction, + parseEvaluate, + parseGetProperties, + parseGlobalLexicalScopeNames, + parseReleaseObject, + parseReleaseObjectGroup, + type CdpCallArgument, + type CdpExecutionContextSelector, +} from './cdp-params.ts' +import { RuntimeObjectTable, type RuntimeObjectObserver } from './object-table.ts' +import type { RuntimeObjectRoute } from './object-table.ts' + +/** Runtime router layered over the common per-connection realm sessions. */ +export class RuntimeDomainSession { + private readonly objects: RuntimeObjectTable + private readonly announcedContexts = new Set() + private readonly consoleDisposers = new Map void>() + private readonly unsubscribeRealms: () => void + private enabled = false + private closed = false + + constructor( + private readonly transport: CdpTransport, + private readonly realms: InspectorRealmSessionSet, + ) { + this.objects = new RuntimeObjectTable(realms.connectionId) + this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) }) + } + + /** + * Handle methods that require cross-realm Runtime coordination. + * @param request - Parsed CDP request. + * @returns Whether this domain owns the method or object id. + */ + handle(request: CdpRequest): boolean { + switch (request.method) { + case 'Runtime.enable': + this.respond(request, () => this.enable()) + return true + case 'Runtime.disable': + this.respond(request, () => this.disable()) + return true + case 'Runtime.evaluate': + this.respond(request, () => this.evaluate(request.params)) + return true + case 'Runtime.getProperties': + return this.getProperties(request) + case 'Runtime.callFunctionOn': + return this.callFunction(request) + case 'Runtime.awaitPromise': + return this.awaitPromise(request) + case 'Runtime.releaseObject': + return this.releaseObject(request) + case 'Runtime.releaseObjectGroup': + this.respond(request, () => this.releaseObjectGroup(request.params)) + return true + case 'Runtime.globalLexicalScopeNames': + this.respond(request, () => this.globalLexicalScopeNames(request.params)) + return true + case 'Runtime.discardConsoleEntries': + this.respond(request, () => this.discardConsoleEntries()) + return true + default: + if (request.method.startsWith('Runtime.')) { + const reason = this.unsupportedNativeRoute(request.params) + if (reason !== undefined) { + this.sendError(request, reason) + return true + } + } + return false + } + } + + /** Release this connection's object routes and realm subscription. */ + close(): void { + if (this.closed) return + this.closed = true + this.unsubscribeRealms() + for (const dispose of this.consoleDisposers.values()) dispose() + this.consoleDisposers.clear() + this.objects.clear() + this.announcedContexts.clear() + } + + /** + * Install semantic object recognition shared with the DOM adapter. + * @param observer - Callback invoked for objects carrying semantic references. + */ + setObjectObserver(observer: RuntimeObjectObserver): void { + this.objects.setObserver(observer) + } + + /** + * Resolve a connection-local CDP object id for another domain adapter. + * @param objectId - CDP object id allocated by this Runtime session. + * @returns Its realm and backend handle when still live. + */ + objectRoute(objectId: string): RuntimeObjectRoute | undefined { + return this.objects.resolve(objectId) + } + + /** + * Project a completion produced by another domain through this connection's object table. + * @param realm - Realm session that owns the completion. + * @param completion - Realm-neutral result and exception fields. + * @param group - Object group assigned to exposed handles. + * @returns CDP Runtime result fields. + */ + projectCompletion( + realm: InspectorRealmSession, + completion: RuntimeCompletion, + group: string | undefined, + ): object { + return this.objects.completion(realm, completion, group) + } + + /** + * Project one Runtime value produced by another domain. + * @param realm - Realm session that owns the value. + * @param value - Realm-neutral Runtime value. + * @param group - Object group assigned to an exposed handle. + * @returns CDP RemoteObject fields. + */ + projectRemoteObject( + realm: InspectorRealmSession, + value: RuntimeRemoteObject, + group: string | undefined, + ): Readonly> { + return this.objects.remote(realm, value, group) + } + + /** + * Forget connection-local ids retained for another domain's object group. + * @param group - Object group whose projected ids have expired. + */ + releaseProjectedGroup(group: string): void { + this.objects.releaseGroup(group) + } + + /** + * Replace common object ids with native backend handles in a Host-only request. + * @param params - Parsed CDP parameters that may contain nested object ids. + * @returns A detached parameter record suitable for the native Host protocol. + */ + nativeParameters(params: Readonly>): Readonly> { + const visit = (value: unknown, key: string | undefined): unknown => { + if ((key === 'objectId' || key?.endsWith('ObjectId') === true) && typeof value === 'string') { + const route = this.objects.resolve(value) + if (route === undefined) return value + if (route.realm.nativeDomains.state === 'unsupported') throw new Error(route.realm.nativeDomains.reason) + return route.handle + } + if (Array.isArray(value)) return value.map(item => visit(item, undefined)) + if (typeof value !== 'object' || value === null) return value + return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, visit(item, name)])) + } + return visit(params, undefined) as Readonly> + } + + /** + * Resolve one realm-registry expression to a connection-local object id. + * @param source - Source generation that owns the Cordis tree node. + * @param expression - Side-effect-free realm object lookup. + * @param objectGroup - Optional DevTools retention group. + * @returns The CDP RemoteObject fields. + */ + async resolveObject( + source: InspectorSourceDescriptor, + expression: string, + objectGroup: string | undefined, + ): Promise>> { + const realm = this.realms.bySource(source) + if (realm === undefined) throw new Error('Cordis realm is no longer connected') + const runtime = runtimeBackend(realm) + const completion = await runtime.evaluate({ + expression, + generatePreview: true, + ...(objectGroup === undefined ? {} : { objectGroup }), + }) + if (completion.exceptionDetails !== undefined) throw new Error('Cordis object lookup failed') + return this.objects.completion(realm, completion, objectGroup).result + } + + private async enable(): Promise { + this.enabled = true + try { + await Promise.all(this.realms.all().map(async (realm) => { await runtimeBackend(realm).enable() })) + for (const realm of this.realms.all()) { + this.attachConsole(realm) + this.announce(realm) + } + return {} + } catch (error) { + this.enabled = false + for (const dispose of this.consoleDisposers.values()) dispose() + this.consoleDisposers.clear() + this.announcedContexts.clear() + await Promise.allSettled(this.realms.all().map(async (realm) => { await runtimeBackend(realm).disable() })) + throw error + } + } + + private async disable(): Promise { + for (const dispose of this.consoleDisposers.values()) dispose() + this.consoleDisposers.clear() + try { + await Promise.all(this.realms.all().map(async (realm) => { await runtimeBackend(realm).disable() })) + } finally { + this.enabled = false + this.objects.clear() + this.announcedContexts.clear() + } + return {} + } + + private async evaluate(params: Readonly>): Promise { + const parsed = parseEvaluate(params) + const realm = this.realmFromSelector(parsed, 'contextId') + const completion = await runtimeBackend(realm).evaluate(parsed.request) + return this.objects.completion(realm, completion, parsed.request.objectGroup) + } + + private getProperties(request: CdpRequest): boolean { + const objectId = request.params.objectId + if (typeof objectId !== 'string') return false + const route = this.objects.resolve(objectId) + if (route === undefined) return false + this.respond(request, async () => { + const parsed = parseGetProperties(request.params) + const properties = await runtimeBackend(route.realm).getProperties({ ...parsed.request, handle: route.handle }) + return this.objects.properties(route.realm, properties, route.group) + }) + return true + } + + private callFunction(request: CdpRequest): boolean { + const objectId = typeof request.params.objectId === 'string' ? request.params.objectId : undefined + const receiver = objectId === undefined ? undefined : this.objects.resolve(objectId) + const selected = this.realmFromOptionalSelector(request.params, 'executionContextId') + if (receiver === undefined && selected === undefined && objectId !== undefined) return false + const realm = receiver?.realm ?? selected ?? this.realms.host() + if (receiver !== undefined && selected !== undefined && receiver.realm !== selected) { + this.sendError(request, 'Runtime.callFunctionOn receiver and execution context belong to different realms') + return true + } + this.respond(request, async () => { + const parsed = parseCallFunction(request.params) + const group = parsed.request.objectGroup ?? receiver?.group + const completion = await runtimeBackend(realm).callFunction({ + ...parsed.request, + ...(receiver === undefined ? {} : { receiver: receiver.handle }), + arguments: parsed.arguments.map(argument => this.routeArgument(realm, argument)), + }) + return this.objects.completion(realm, completion, group) + }) + return true + } + + private awaitPromise(request: CdpRequest): boolean { + const objectId = request.params.promiseObjectId + if (typeof objectId !== 'string') return false + const route = this.objects.resolve(objectId) + if (route === undefined) return false + this.respond(request, async () => { + const parsed = parseAwaitPromise(request.params) + const completion = await runtimeBackend(route.realm).awaitPromise({ ...parsed.request, promise: route.handle }) + return this.objects.completion(route.realm, completion, route.group) + }) + return true + } + + private releaseObject(request: CdpRequest): boolean { + const objectId = request.params.objectId + if (typeof objectId !== 'string') return false + const route = this.objects.resolve(objectId) + if (route === undefined) return false + this.respond(request, async () => { + parseReleaseObject(request.params) + await runtimeBackend(route.realm).releaseObject(route.handle) + this.objects.release(objectId) + return {} + }) + return true + } + + private async releaseObjectGroup(params: Readonly>): Promise { + const group = parseReleaseObjectGroup(params) + const realms = this.objects.realmsInGroup(group) + try { + await Promise.all(realms.map(async (realm) => { await runtimeBackend(realm).releaseObjectGroup(group) })) + } finally { + this.objects.releaseGroup(group) + } + return {} + } + + private async globalLexicalScopeNames(params: Readonly>): Promise { + const parsed = parseGlobalLexicalScopeNames(params) + const realm = this.realmFromSelector(parsed, 'executionContextId') + return { names: await runtimeBackend(realm).globalLexicalScopeNames() } + } + + private async discardConsoleEntries(): Promise { + await Promise.all(this.realms.all().map(async (realm) => { + if (realm.console.state === 'supported') await realm.console.backend.clear() + await runtimeBackend(realm).releaseObjectGroup('console') + })) + this.objects.releaseGroup('console') + return {} + } + + private realmFromSelector( + params: CdpExecutionContextSelector, + numericKey: 'contextId' | 'executionContextId', + ): InspectorRealmSession { + return this.realmFromOptionalSelector(params, numericKey) ?? this.realms.host() + } + + private realmFromOptionalSelector( + params: CdpExecutionContextSelector, + numericKey: 'contextId' | 'executionContextId', + ): InspectorRealmSession | undefined { + const numeric = params[numericKey] + if (typeof numeric === 'number' && Number.isSafeInteger(numeric)) { + const realm = this.realms.byContextId(numeric) + if (realm !== undefined) return realm + if (numeric < 0) throw new Error('Client execution context is no longer available') + return this.realms.host() + } + const unique = params.uniqueContextId + if (typeof unique === 'string') { + const realm = this.realms.byUniqueContextId(unique) + if (realm !== undefined) return realm + if (unique.startsWith('dsh-client:')) throw new Error('Client execution context is no longer available') + return this.realms.host() + } + return undefined + } + + private routeArgument( + realm: InspectorRealmSession, + argument: CdpCallArgument, + ): RuntimeCallArgument { + if (argument.kind !== 'object') return argument + const route = this.objects.resolve(argument.objectId) + if (route === undefined || route.realm !== realm) { + throw new Error('Runtime.callFunctionOn cannot pass an object between realms') + } + return { kind: 'object', handle: route.handle } + } + + private unsupportedNativeRoute(params: Readonly>): string | undefined { + for (const key of ['contextId', 'executionContextId'] as const) { + const contextId = params[key] + if (typeof contextId !== 'number') continue + const realm = this.realms.byContextId(contextId) + if (realm?.nativeDomains.state === 'unsupported') return realm.nativeDomains.reason + if (contextId < 0 && realm === undefined) return 'Client execution context is no longer available' + } + if (typeof params.uniqueContextId === 'string') { + const realm = this.realms.byUniqueContextId(params.uniqueContextId) + if (realm?.nativeDomains.state === 'unsupported') return realm.nativeDomains.reason + if (params.uniqueContextId.startsWith('dsh-client:') && realm === undefined) { + return 'Client execution context is no longer available' + } + } + for (const [key, value] of Object.entries(params)) { + if (!key.endsWith('ObjectId') && key !== 'objectId') continue + if (typeof value !== 'string') continue + const route = this.objects.resolve(value) + if (route?.realm.nativeDomains.state === 'unsupported') return route.realm.nativeDomains.reason + } + return undefined + } + + private receiveRealm(event: InspectorRealmSessionEvent): void { + if (event.type === 'opened') { + if (this.enabled) { + void runtimeBackend(event.session).enable().then( + () => { + this.attachConsole(event.session) + this.announce(event.session) + }, + () => { event.session.close() }, + ) + } + return + } + this.consoleDisposers.get(event.session.descriptor.realmId)?.() + this.consoleDisposers.delete(event.session.descriptor.realmId) + this.objects.releaseRealm(event.session) + this.destroy(event.session) + } + + private attachConsole(realm: InspectorRealmSession): void { + if (realm.console.state === 'unsupported' || this.consoleDisposers.has(realm.descriptor.realmId)) return + this.consoleDisposers.set(realm.descriptor.realmId, realm.console.backend.subscribe((event) => { + if (!this.enabled) return + this.transport.send(this.objects.consoleEvent(realm, event)) + })) + } + + private announce(realm: InspectorRealmSession): void { + if (!this.enabled || realm.context.kind !== 'synthetic' || this.announcedContexts.has(realm.context.id)) return + this.announcedContexts.add(realm.context.id) + this.transport.send({ + method: 'Runtime.executionContextCreated', + params: { + context: { + id: realm.context.id, + uniqueId: realm.context.uniqueId, + origin: realm.context.origin, + name: `Client — ${realm.descriptor.label}`, + auxData: { isDefault: false, type: 'dsh-client', sourceId: realm.descriptor.sourceId }, + }, + }, + }) + } + + private destroy(realm: InspectorRealmSession): void { + if (realm.context.kind !== 'synthetic' || !this.announcedContexts.delete(realm.context.id)) return + this.transport.send({ + method: 'Runtime.executionContextDestroyed', + params: { + executionContextId: realm.context.id, + executionContextUniqueId: realm.context.uniqueId, + }, + }) + } + + private respond(request: CdpRequest, operation: () => Promise): void { + respondToCdpRequest(this.transport, request, operation) + } + + private sendError(request: CdpRequest, message: string): void { + this.transport.send(cdpError(request.id, -32000, message)) + } +} + +function runtimeBackend(realm: InspectorRealmSession): RuntimeBackend { + if (realm.runtime.state === 'unsupported') throw new Error(realm.runtime.reason) + return realm.runtime.backend +} diff --git a/packages/experimental/inspector/src/worker/cdp/ids.ts b/packages/experimental/inspector/src/worker/cdp/ids.ts new file mode 100644 index 0000000000..ae72f936f6 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/ids.ts @@ -0,0 +1,53 @@ +/** Opaque identifiers owned by one Worker-side Chrome DevTools connection. */ + +import type { InspectorId } from '../../shared/identity.ts' + +declare const cdpNumericIdBrand: unique symbol + +/** Number branded with one Chrome CDP identity role. */ +export type CdpNumericId = number & { readonly [cdpNumericIdBrand]: Role } + +/** Identity of one DevTools connection inside the Worker. */ +export type InspectorConnectionId = InspectorId<'InspectorConnectionId'> + +/** Runtime object id scoped to one DevTools connection. */ +export type CdpRemoteObjectId = InspectorId<'CdpRemoteObjectId'> + +/** Debugger script id scoped to one DevTools connection. */ +export type CdpScriptId = InspectorId<'CdpScriptId'> + +/** Debugger call-frame id scoped to one paused DevTools session. */ +export type CdpCallFrameId = InspectorId<'CdpCallFrameId'> + +/** Runtime execution-context id scoped to one DevTools target. */ +export type CdpExecutionContextId = CdpNumericId<'CdpExecutionContextId'> + +/** DOM frontend node id scoped to one DevTools document. */ +export type CdpNodeId = CdpNumericId<'CdpNodeId'> + +/** DOM backend node id stable across connection-local document projections. */ +export type CdpBackendNodeId = CdpNumericId<'CdpBackendNodeId'> + +/** + * Validate and brand a string id allocated or accepted by the CDP adapter. + * @param value - CDP identifier text. + * @param label - Field named in validation failures. + * @returns The branded CDP identifier. + */ +export function cdpStringId(value: string, label: string): InspectorId { + if (value.length === 0 || value.length > 16_384) { + throw new Error(`inspector CDP: ${label} must contain 1 to 16384 characters`) + } + return value as InspectorId +} + +/** + * Validate and brand a positive numeric id allocated by the CDP adapter. + * @param value - CDP identifier number. + * @param label - Field named in validation failures. + * @returns The branded numeric identifier. + */ +export function cdpNumericId(value: number, label: string): CdpNumericId { + if (!Number.isSafeInteger(value) || value < 1) throw new Error(`inspector CDP: ${label} must be a positive integer`) + return value as CdpNumericId +} diff --git a/packages/experimental/inspector/src/worker/cdp/protocol.ts b/packages/experimental/inspector/src/worker/cdp/protocol.ts new file mode 100644 index 0000000000..be2a9d6640 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/protocol.ts @@ -0,0 +1,82 @@ +/** Minimal CDP request and transport types owned by the Worker. */ + +import { isPlainObject } from '../../shared/json.ts' + +/** Parsed client request. */ +export interface CdpRequest { + readonly id: number + readonly method: string + readonly params: Readonly> +} + +/** Outbound CDP event. */ +export interface CdpNotification { + readonly method: string + readonly params: Readonly> +} + +/** A connected DevTools transport. */ +export interface CdpTransport { + send(payload: unknown): void + close(): void +} + +/** + * Parse one DevTools request before routing it. + * @param value - Untrusted decoded WebSocket payload. + * @returns The validated request envelope. + */ +export function parseCdpRequest(value: unknown): CdpRequest { + if (!isPlainObject(value) + || !Number.isSafeInteger(value.id) + || (value.id as number) < 0 + || typeof value.method !== 'string' + || value.method.length === 0 + || (value.params !== undefined && !isPlainObject(value.params))) { + throw new Error('inspector CDP: invalid request') + } + return { + id: value.id as number, + method: value.method, + params: value.params ?? {}, + } +} + +/** + * Build a stable CDP error response. + * @param id - Request id copied from the caller. + * @param code - JSON-RPC error code. + * @param message - Human-readable failure reason. + * @returns The CDP error envelope. + */ +export function cdpError(id: number, code: number, message: string): object { + return { id, error: { code, message } } +} + +/** + * Send one failed CDP operation using the domain error code. + * @param transport - Connection receiving the response. + * @param request - Request supplying the response id. + * @param error - Rejection or synchronous error to render. + */ +export function sendCdpFailure(transport: CdpTransport, request: CdpRequest, error: unknown): void { + const message = error instanceof Error ? error.message : String(error) + transport.send(cdpError(request.id, -32000, message)) +} + +/** + * Settle an asynchronous CDP operation through one transport. + * @param transport - Connection receiving the response. + * @param request - Request supplying the response id. + * @param operation - Domain operation that produces the result. + */ +export function respondToCdpRequest( + transport: CdpTransport, + request: CdpRequest, + operation: () => Promise, +): void { + void operation().then( + (result) => { transport.send({ id: request.id, result }) }, + (error: unknown) => { sendCdpFailure(transport, request, error) }, + ) +} diff --git a/packages/experimental/inspector/src/worker/cdp/realm-sessions.ts b/packages/experimental/inspector/src/worker/cdp/realm-sessions.ts new file mode 100644 index 0000000000..32f7baeed9 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/realm-sessions.ts @@ -0,0 +1,128 @@ +/** Per-DevTools-connection sessions opened from the shared realm registry. */ + +import { randomUUID } from 'node:crypto' +import { inspectorId } from '../../shared/identity.ts' +import type { InspectorRealmId } from '../../shared/cdp/ids.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorRealmEvent, InspectorRealmRegistry } from '../inspection/realm-store.ts' +import type { InspectorRealm, InspectorRealmSession } from '../inspection/realm.ts' +import type { InspectorConnectionId } from './ids.ts' + +/** Realm-session lifecycle observed by connection-local CDP domains. */ +export type InspectorRealmSessionEvent = + | { readonly type: 'opened'; readonly session: InspectorRealmSession } + | { readonly type: 'closed'; readonly session: InspectorRealmSession } + +/** Owns exactly one backend session per active realm for one DevTools connection. */ +export class InspectorRealmSessionSet { + /** Opaque identity shared by every domain and object table on this DevTools connection. */ + readonly connectionId: InspectorConnectionId = inspectorId<'InspectorConnectionId'>(randomUUID(), 'connectionId') + private readonly sessions = new Map() + private readonly listeners = new Set<(event: InspectorRealmSessionEvent) => void>() + private readonly unsubscribeRealms: () => void + private closed = false + + constructor(private readonly realms: InspectorRealmRegistry) { + for (const realm of realms.realms()) this.open(realm) + this.unsubscribeRealms = realms.subscribe((event) => { this.receiveRealm(event) }) + } + + /** + * Return active sessions in the registry's deterministic order. + * @returns Host followed by connected Clients. + */ + all(): InspectorRealmSession[] { + return this.realms.realms() + .map(realm => this.sessions.get(realm.descriptor.realmId)) + .filter((session): session is InspectorRealmSession => session !== undefined) + } + + /** + * Return the required Host session. + * @returns The connection-local Host realm session. + */ + host(): InspectorRealmSession { + const session = this.sessions.get(this.realms.host.descriptor.realmId) + if (session === undefined) throw new Error('Host Inspector realm session is unavailable') + return session + } + + /** + * Resolve one synthetic Client context. + * @param contextId - Numeric CDP execution-context id. + * @returns Its realm session when currently connected. + */ + byContextId(contextId: number): InspectorRealmSession | undefined { + const realm = this.realms.byContextId(contextId) + return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId) + } + + /** + * Resolve one globally unique Client context. + * @param uniqueId - CDP unique execution-context id. + * @returns Its realm session when currently connected. + */ + byUniqueContextId(uniqueId: string): InspectorRealmSession | undefined { + const realm = this.realms.byUniqueContextId(uniqueId) + return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId) + } + + /** + * Resolve one active source generation to this connection's realm session. + * @param source - Source identity retained by a Cordis tree node. + * @returns The matching realm session. + */ + bySource(source: InspectorSourceDescriptor): InspectorRealmSession | undefined { + const realm = this.realms.bySource(source) + return realm === undefined ? undefined : this.sessions.get(realm.descriptor.realmId) + } + + /** + * Subscribe to connection-local realm session lifecycle. + * @param listener - Session observer. + * @returns A disposer removing the observer. + */ + subscribe(listener: (event: InspectorRealmSessionEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Close all realm sessions and stop tracking the registry. */ + close(): void { + if (this.closed) return + this.closed = true + this.unsubscribeRealms() + for (const session of this.sessions.values()) session.close() + this.sessions.clear() + this.listeners.clear() + } + + private receiveRealm(event: InspectorRealmEvent): void { + if (event.type === 'opened') { + const session = this.open(event.realm) + this.emit({ type: 'opened', session }) + return + } + const session = this.sessions.get(event.realm.descriptor.realmId) + if (session === undefined) return + this.sessions.delete(event.realm.descriptor.realmId) + session.close() + this.emit({ type: 'closed', session }) + } + + private open(realm: InspectorRealm): InspectorRealmSession { + const session = realm.openSession() + this.sessions.set(realm.descriptor.realmId, session) + return session + } + + private emit(event: InspectorRealmSessionEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One CDP domain cannot prevent sibling domains from observing realm lifecycle. + } + } + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/session.ts b/packages/experimental/inspector/src/worker/cdp/session.ts new file mode 100644 index 0000000000..8ca7c1f686 --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/session.ts @@ -0,0 +1,117 @@ +/** One DevTools connection: explicit local-domain routing plus a private Host V8 session. */ + +import { cdpError, parseCdpRequest, type CdpTransport } from './protocol.ts' +import { NetworkDomain, type NetworkSink } from './domains/network/session.ts' +import { CDP_METHOD_NOT_HANDLED, handleScaffold, type CdpTargetDescriptor } from './target.ts' +import { RuntimeDomainSession } from './domains/runtime/index.ts' +import { DebuggerDomainSession } from './domains/debugger/index.ts' +import { CordisDomSession, type CordisDomBackend } from './domains/dom/index.ts' +import type { InspectorSourceRegistry } from '../bridge/hub.ts' +import { HostNativeDomainSession } from './domains/native.ts' +import { InspectorRealmSessionSet } from './realm-sessions.ts' +import type { InspectorRealmRegistry } from '../inspection/realm-store.ts' +import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts' + +/** Per-connection CDP dispatcher. */ +export class CdpSession implements NetworkSink { + private readonly realms: InspectorRealmSessionSet + private readonly nativeDomains: HostNativeDomainSession + private readonly runtime: RuntimeDomainSession + private readonly debugger: DebuggerDomainSession + private readonly dom: CordisDomSession + private diagnosticsEnabled = false + private readonly unsubscribeSources: () => void + + constructor( + private readonly transport: CdpTransport, + private readonly target: CdpTargetDescriptor, + private readonly sources: InspectorSourceRegistry, + private readonly network: NetworkDomain, + realmRegistry: InspectorRealmRegistry, + domBackend: CordisDomBackend, + private readonly cordisTrees: CordisRuntimeTreeReader, + ) { + this.realms = new InspectorRealmSessionSet(realmRegistry) + const native = this.realms.host().nativeDomains + if (native.state === 'unsupported') throw new Error(native.reason) + this.nativeDomains = new HostNativeDomainSession(transport, native.backend) + this.runtime = new RuntimeDomainSession(transport, this.realms) + this.debugger = new DebuggerDomainSession(transport, this.realms, this.runtime) + this.dom = new CordisDomSession(transport, domBackend, this.runtime) + this.runtime.setObjectObserver((objectId, realm, reference, group) => + this.dom.bindObject(objectId, realm, reference, group)) + this.unsubscribeSources = sources.subscribeStatus(() => { + if (this.diagnosticsEnabled) this.sendEvent('DSHInspector.sourcesChanged', { sources: this.sources.describe() }) + }) + } + + /** + * Parse and dispatch one raw CDP request. Invalid frames close this client only. + * @param value - Untrusted decoded WebSocket payload. + */ + receive(value: unknown): void { + let request + try { + request = parseCdpRequest(value) + } catch { + this.transport.close() + return + } + try { + if (request.method === 'Runtime.releaseObject') this.dom.releaseObject(request.params.objectId) + if (request.method === 'Runtime.releaseObjectGroup') this.dom.releaseObjectGroup(request.params.objectGroup) + if (this.dom.handle(request)) return + if (this.runtime.handle(request)) return + if (this.debugger.handle(request)) return + if (this.nativeDomains.owns(request.method)) { + this.nativeDomains.handle({ ...request, params: this.runtime.nativeParameters(request.params) }) + return + } + let result: unknown + if (request.method.startsWith('Network.')) { + result = this.network.handle(request.method, request.params, this) + } else if (request.method === 'DSHInspector.enable') { + this.diagnosticsEnabled = true + result = { sources: this.sources.describe() } + } else if (request.method === 'DSHInspector.disable') { + this.diagnosticsEnabled = false + result = {} + } else if (request.method === 'DSHInspector.getSources') { + result = { sources: this.sources.describe() } + } else if (request.method === 'DSHInspector.getCordisTree') { + void this.cordisTrees.getTree().then( + (tree) => { this.transport.send({ id: request.id, result: { tree } }) }, + (error: unknown) => { + this.transport.send(cdpError(request.id, -32000, error instanceof Error ? error.message : String(error))) + }, + ) + return + } else { + result = handleScaffold(request, this.target) + if (result === CDP_METHOD_NOT_HANDLED) { + this.transport.send(cdpError(request.id, -32601, `Method not found: ${request.method}`)) + return + } + } + this.transport.send({ id: request.id, result }) + } catch (error) { + this.transport.send(cdpError(request.id, -32000, error instanceof Error ? error.message : String(error))) + } + } + + /** Push one CDP event. */ + sendEvent(method: string, params: Readonly>): void { + this.transport.send({ method, params }) + } + + /** Release every connection-owned V8 and domain resource. */ + close(): void { + this.unsubscribeSources() + this.network.detach(this) + this.dom.close() + this.runtime.close() + this.debugger.close() + this.nativeDomains.close() + this.realms.close() + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/target.ts b/packages/experimental/inspector/src/worker/cdp/target.ts new file mode 100644 index 0000000000..bdfcd4e50d --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/target.ts @@ -0,0 +1,77 @@ +/** Minimal page-target CDP methods required to expose Network, Console, and Sources together. */ + +import type { CdpRequest } from './protocol.ts' + +/** Sentinel distinguishing an unowned method from an owned method returning undefined. */ +export const CDP_METHOD_NOT_HANDLED = Symbol('CDP_METHOD_NOT_HANDLED') + +/** Page-target identity used by discovery and scaffold responses. */ +export interface CdpTargetDescriptor { + readonly targetId: string + readonly title: string +} + +/** + * Handle one Worker-local identity or page scaffold method. + * @param request - Parsed CDP request. + * @param target - Synthetic page-target identity. + * @returns A response result or the unowned-method sentinel. + */ +export function handleScaffold( + request: CdpRequest, + target: CdpTargetDescriptor, +): object | typeof CDP_METHOD_NOT_HANDLED { + const frame = { + id: 'dsh-inspector-host-frame', + loaderId: 'dsh-inspector-loader', + url: 'dsh://host', + domainAndRegistry: '', + securityOrigin: 'dsh://host', + mimeType: 'text/html', + secureContextType: 'Secure', + crossOriginIsolatedContextType: 'NotIsolated', + gatedAPIFeatures: [], + } + switch (request.method) { + case 'Page.enable': + case 'Page.disable': + case 'Page.setLifecycleEventsEnabled': + case 'Target.setDiscoverTargets': + case 'Target.setAutoAttach': + case 'Log.enable': + case 'Log.disable': + case 'Console.enable': + case 'Console.disable': + return {} + case 'Page.getFrameTree': + return { frameTree: { frame, childFrames: [] } } + case 'Page.getResourceTree': + return { frameTree: { frame, resources: [] } } + case 'Page.getNavigationHistory': + return { + currentIndex: 0, + entries: [{ id: 1, url: frame.url, userTypedURL: frame.url, title: target.title, transitionType: 'typed' }], + } + case 'Target.getTargetInfo': + return { + targetInfo: { + targetId: target.targetId, + type: 'page', + title: target.title, + url: frame.url, + attached: true, + canAccessOpener: false, + }, + } + case 'Browser.getVersion': + return { + protocolVersion: '1.3', + product: 'dsh-experimental-inspector/0', + revision: '@experimental', + userAgent: 'dsh-experimental-inspector', + jsVersion: process.versions.v8, + } + default: + return CDP_METHOD_NOT_HANDLED + } +} diff --git a/packages/experimental/inspector/src/worker/entry.ts b/packages/experimental/inspector/src/worker/entry.ts new file mode 100644 index 0000000000..c1f5ff9495 --- /dev/null +++ b/packages/experimental/inspector/src/worker/entry.ts @@ -0,0 +1,55 @@ +/** Node Worker bootstrap for the experimental Inspector. */ + +import { MessagePort, parentPort, workerData } from 'node:worker_threads' +import type { InspectorWorkerBoot, InspectorWorkerControl } from '../shared/bridge/messages/control.ts' +import { parseInspectorHostControl, parseInspectorWorkerConfig } from '../shared/bridge/control-codec.ts' +import { isPlainObject } from '../shared/json.ts' +import { startInspectorWorker } from './server.ts' + +if (parentPort === null) throw new Error('experimental inspector: Worker entry loaded on the main thread') +const controlPort = parentPort + +const bootData = workerData as unknown +if (!isPlainObject(bootData) + || !(bootData.hostSourcePort instanceof MessagePort)) { + throw new Error('experimental inspector: invalid Worker boot data') +} +const boot: InspectorWorkerBoot = { + hostSourcePort: bootData.hostSourcePort, + config: parseInspectorWorkerConfig(bootData.config), +} + +let runtime: Awaited> | undefined +let stopping: Promise | undefined + +const stop = (): Promise => { + stopping ??= (async () => { + await runtime?.close() + controlPort.postMessage({ type: 'stopped' } satisfies InspectorWorkerControl) + controlPort.close() + })() + return stopping +} + +controlPort.on('message', (message: unknown) => { + try { + parseInspectorHostControl(message) + void stop() + } catch (error) { + controlPort.postMessage({ + type: 'failure', + message: error instanceof Error ? error.message : String(error), + } satisfies InspectorWorkerControl) + } +}) + +try { + runtime = await startInspectorWorker(boot) + controlPort.postMessage({ type: 'ready', ...runtime.endpoint } satisfies InspectorWorkerControl) +} catch (error) { + controlPort.postMessage({ + type: 'failure', + message: error instanceof Error ? error.message : String(error), + } satisfies InspectorWorkerControl) + await stop() +} diff --git a/packages/experimental/inspector/src/worker/inspection/realm-store.ts b/packages/experimental/inspector/src/worker/inspection/realm-store.ts new file mode 100644 index 0000000000..9228a39253 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/realm-store.ts @@ -0,0 +1,116 @@ +/** Worker-owned registry of Host and Client realm definitions. */ + +import type { ClientRuntimeRouter, ClientRuntimeTargetEvent } from '../bridge/runtime-rpc.ts' +import type { ClientSourceRouter } from '../bridge/source-rpc.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import { ClientInspectorRealm } from '../realms/client/index.ts' +import type { InspectorRealm } from './realm.ts' + +/** Realm admission and removal observed by each DevTools connection. */ +export type InspectorRealmEvent = + | { readonly type: 'opened'; readonly realm: InspectorRealm } + | { readonly type: 'closed'; readonly realm: InspectorRealm } + +/** Authoritative collection of all currently executable realms. */ +export class InspectorRealmRegistry { + private readonly clientsBySource = new Map() + private readonly listeners = new Set<(event: InspectorRealmEvent) => void>() + private readonly unsubscribeClients: () => void + + constructor( + readonly host: InspectorRealm, + private readonly clients: ClientRuntimeRouter, + private readonly clientSources: ClientSourceRouter, + ) { + for (const target of clients.targets()) this.openClient(target) + this.unsubscribeClients = clients.subscribe((event) => { this.receiveClient(event) }) + } + + /** + * Return the realm admission order used by every connection-local session set. + * @returns Host followed by active Clients. + */ + realms(): InspectorRealm[] { + return [this.host, ...this.clientsBySource.values()] + } + + /** + * Resolve one synthetic Client execution context. + * @param contextId - Numeric CDP execution-context id. + * @returns The active realm when the id belongs to a Client. + */ + byContextId(contextId: number): InspectorRealm | undefined { + for (const realm of this.clientsBySource.values()) { + if (realm.context.kind === 'synthetic' && realm.context.id === contextId) return realm + } + return undefined + } + + /** + * Resolve one globally unique Client execution context. + * @param uniqueId - CDP unique execution-context id. + * @returns The active realm when the id belongs to a Client. + */ + byUniqueContextId(uniqueId: string): InspectorRealm | undefined { + for (const realm of this.clientsBySource.values()) { + if (realm.context.kind === 'synthetic' && realm.context.uniqueId === uniqueId) return realm + } + return undefined + } + + /** + * Resolve the realm for one active source generation. + * @param source - Source identity retained by a Cordis tree node. + * @returns The matching active realm. + */ + bySource(source: InspectorSourceDescriptor): InspectorRealm | undefined { + if (source.kind === 'host') return this.host + const realm = this.clientsBySource.get(source.sourceId) + return realm?.descriptor.generation === source.generation ? realm : undefined + } + + /** + * Subscribe to Client realm admission and removal. + * @param listener - Registry observer. + * @returns A disposer removing the observer. + */ + subscribe(listener: (event: InspectorRealmEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Stop observing Client targets and clear registry listeners. */ + close(): void { + this.unsubscribeClients() + this.clientsBySource.clear() + this.listeners.clear() + } + + private receiveClient(event: ClientRuntimeTargetEvent): void { + if (event.type === 'opened') { + const realm = this.openClient(event.target) + this.emit({ type: 'opened', realm }) + return + } + const realm = this.clientsBySource.get(event.target.source.sourceId) + if (realm === undefined || realm.target !== event.target) return + this.clientsBySource.delete(event.target.source.sourceId) + this.emit({ type: 'closed', realm }) + } + + private openClient(target: ClientRuntimeTargetEvent['target']): ClientInspectorRealm { + const realm = new ClientInspectorRealm(target, this.clients, this.clientSources) + this.clientsBySource.set(target.source.sourceId, realm) + return realm + } + + private emit(event: InspectorRealmEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One DevTools connection cannot disrupt realm delivery to sibling connections. + } + } + } +} diff --git a/packages/experimental/inspector/src/worker/inspection/realm.ts b/packages/experimental/inspector/src/worker/inspection/realm.ts new file mode 100644 index 0000000000..740d5bf44e --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/realm.ts @@ -0,0 +1,54 @@ +/** Worker-owned lifecycle model for active Host and Client JavaScript realms. */ + +import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts' +import type { InspectorRealmCapabilities } from '../../shared/cdp/capabilities.ts' +import type { InspectorRealmId } from '../../shared/cdp/ids.ts' +import type { + ConsoleBackend, + DebuggerBackend, + NativeDomainBackend, + RealmCapability, + RuntimeBackend, + SourceBackend, +} from '../../shared/cdp/realm.ts' + +/** Stable description of one active realm generation. */ +export interface InspectorRealmDescriptor { + readonly realmId: InspectorRealmId + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly kind: 'host' | 'client' + readonly label: string +} + +/** Execution-context ownership for one realm. */ +export type InspectorRealmContext = + | { readonly kind: 'native' } + | { + readonly kind: 'synthetic' + readonly id: number + readonly uniqueId: string + readonly origin: string + } + +/** Capabilities bound to one realm and one DevTools connection. */ +export interface InspectorRealmSession { + readonly descriptor: InspectorRealmDescriptor + readonly context: InspectorRealmContext + readonly runtime: RealmCapability + readonly console: RealmCapability + readonly sources: RealmCapability + readonly debugger: RealmCapability + readonly nativeDomains: RealmCapability + /** Release every connection-owned backend resource. */ + close(): void +} + +/** Active realm that can create isolated state for each DevTools connection. */ +export interface InspectorRealm { + readonly descriptor: InspectorRealmDescriptor + readonly context: InspectorRealmContext + readonly capabilities: InspectorRealmCapabilities + /** @returns Isolated backend state for one DevTools connection. */ + openSession(): InspectorRealmSession +} diff --git a/packages/experimental/inspector/src/worker/realms/client/bridge.ts b/packages/experimental/inspector/src/worker/realms/client/bridge.ts new file mode 100644 index 0000000000..3ea3da35d7 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/bridge.ts @@ -0,0 +1,26 @@ +/** Worker-side bridge dependencies for one connected Client realm. */ + +import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts' +import type { ClientSourceRouter } from '../../bridge/source-rpc.ts' + +/** Typed bridge services used by all Client realm backend adapters. */ +export interface ClientRealmBridge { + readonly target: ClientRuntimeTarget + readonly runtime: ClientRuntimeRouter + readonly sources: ClientSourceRouter +} + +/** + * Bind one Client source generation to the Worker bridge services that can address it. + * @param target - Active Client source generation and execution context. + * @param runtime - Runtime and Console RPC router. + * @param sources - Source-catalog RPC router. + * @returns The immutable Client realm bridge. + */ +export function createClientRealmBridge( + target: ClientRuntimeTarget, + runtime: ClientRuntimeRouter, + sources: ClientSourceRouter, +): ClientRealmBridge { + return { target, runtime, sources } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/console.ts b/packages/experimental/inspector/src/worker/realms/client/console.ts new file mode 100644 index 0000000000..0d74fadb8f --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/console.ts @@ -0,0 +1,40 @@ +/** ConsoleBackend over the typed Client Console event transport. */ + +import type { ClientRuntimeSessionId } from '../../../shared/bridge/ids.ts' +import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts' +import type { RuntimeConsoleBackendEvent } from '../../../shared/cdp/index.ts' +import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts' +import type { ConsoleBackend } from '../../../shared/cdp/realm.ts' +import { clientConsoleEvent } from './values.ts' +import type { ClientScriptIdentity } from './scripts.ts' + +/** Adapts session-local Client Console events to common Runtime values. */ +export class ClientConsoleBackend implements ConsoleBackend { + private readonly disposers = new Set<() => void>() + + constructor( + private readonly target: ClientRuntimeTarget, + private readonly sessionId: ClientRuntimeSessionId, + private readonly router: ClientRuntimeRouter, + private readonly scriptIds: ClientScriptIdentity, + ) {} + + subscribe(listener: (event: RuntimeConsoleBackendEvent) => void): () => void { + const dispose = this.router.subscribeConsole(this.target, this.sessionId, (event) => { + listener(clientConsoleEvent(event, scriptKey => this.scriptIds.toRuntime(scriptKey))) + }) + this.disposers.add(dispose) + return () => { + if (!this.disposers.delete(dispose)) return + dispose() + } + } + + async clear(): Promise {} + + /** Disable every active Console subscription for this connection. */ + close(): void { + for (const dispose of this.disposers) dispose() + this.disposers.clear() + } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/debugger.ts b/packages/experimental/inspector/src/worker/realms/client/debugger.ts new file mode 100644 index 0000000000..9baaf8a7ac --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/debugger.ts @@ -0,0 +1,11 @@ +/** Explicit Client debugger capability until a pause-safe page agent exists. */ + +import type { DebuggerBackend, RealmCapability } from '../../../shared/cdp/realm.ts' + +/** + * Report the unavailable Client debugger backend. + * @returns The typed unsupported result used by every Client realm session. + */ +export function clientDebuggerCapability(): RealmCapability { + return { state: 'unsupported', reason: 'Client native debugging is unavailable' } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/index.ts b/packages/experimental/inspector/src/worker/realms/client/index.ts new file mode 100644 index 0000000000..5952f9c4db --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/index.ts @@ -0,0 +1,104 @@ +/** Client realm definition assembled from independent Runtime, Console, and Source backends. */ + +import { randomUUID } from 'node:crypto' +import { inspectorId } from '../../../shared/identity.ts' +import { ClientConsoleBackend } from './console.ts' +import { ClientRuntimeBackend } from './runtime.ts' +import { ClientSourceBackend } from './sources.ts' +import { ClientScriptIdentity } from './scripts.ts' +import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts' +import type { ClientSourceRouter } from '../../bridge/source-rpc.ts' +import type { InspectorRealm, InspectorRealmDescriptor, InspectorRealmSession } from '../../inspection/realm.ts' +import { createClientRealmBridge, type ClientRealmBridge } from './bridge.ts' +import { clientDebuggerCapability } from './debugger.ts' + +const CLIENT_RUNTIME_OPERATIONS = [ + 'evaluate', + 'get-properties', + 'call-function', + 'await-promise', + 'release-object', + 'release-object-group', + 'global-lexical-scope-names', +] as const + +/** Active Client realm exposed through the common Worker realm model. */ +export class ClientInspectorRealm implements InspectorRealm { + readonly descriptor: InspectorRealmDescriptor + readonly context: InspectorRealm['context'] + readonly capabilities: InspectorRealm['capabilities'] + private readonly scriptIds: ClientScriptIdentity + private readonly bridge: ClientRealmBridge + + constructor( + target: ClientRuntimeTarget, + runtimeRouter: ClientRuntimeRouter, + sourceRouter: ClientSourceRouter, + ) { + this.bridge = createClientRealmBridge(target, runtimeRouter, sourceRouter) + this.descriptor = { + realmId: inspectorId<'InspectorRealmId'>(randomUUID(), 'realmId'), + sourceId: target.source.sourceId, + generation: target.source.generation, + kind: 'client', + label: target.source.label, + } + this.context = { + kind: 'synthetic', + id: target.contextId, + uniqueId: target.uniqueContextId, + origin: target.capability.origin, + } + this.scriptIds = new ClientScriptIdentity(target.contextId) + this.capabilities = { + runtime: CLIENT_RUNTIME_OPERATIONS, + console: supports(target, 'client-console') ? ['events', 'exceptions', 'clear'] : [], + sources: supports(target, 'client-sources') ? ['catalog', 'content', 'source-map'] : [], + debugger: [], + } + } + + /** Active source generation represented by this realm. */ + get target(): ClientRuntimeTarget { + return this.bridge.target + } + + /** Open one isolated set of Client backends for a DevTools connection. */ + openSession(): InspectorRealmSession { + const runtimeSessionId = inspectorId<'ClientRuntimeSessionId'>(randomUUID(), 'runtimeSessionId') + const runtime = new ClientRuntimeBackend(this.target, runtimeSessionId, this.bridge.runtime, this.scriptIds) + const console = supports(this.target, 'client-console') + ? new ClientConsoleBackend(this.target, runtimeSessionId, this.bridge.runtime, this.scriptIds) + : undefined + const sources = supports(this.target, 'client-sources') + ? new ClientSourceBackend( + this.target, + inspectorId<'ClientSourceSessionId'>(randomUUID(), 'sourceSessionId'), + this.bridge.sources, + this.scriptIds, + ) + : undefined + return { + descriptor: this.descriptor, + context: this.context, + runtime: { state: 'supported', backend: runtime }, + console: console === undefined + ? { state: 'unsupported', reason: 'Client source does not provide Console events' } + : { state: 'supported', backend: console }, + sources: sources === undefined + ? { state: 'unsupported', reason: 'Client source does not provide a script catalog' } + : { state: 'supported', backend: sources }, + debugger: clientDebuggerCapability(), + nativeDomains: { state: 'unsupported', reason: 'Client realm has no native CDP transport' }, + close: () => { + console?.close() + sources?.close() + runtime.close() + }, + } + } +} + +function supports(target: ClientRuntimeTarget, capability: 'client-console' | 'client-sources'): boolean { + return target.source.capabilities.some(candidate => candidate.type === capability) +} diff --git a/packages/experimental/inspector/src/worker/realms/client/runtime.ts b/packages/experimental/inspector/src/worker/realms/client/runtime.ts new file mode 100644 index 0000000000..ef44975849 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/runtime.ts @@ -0,0 +1,160 @@ +/** RuntimeBackend over the typed Worker-to-Client transport. */ + +import type { + ClientCallArgument, + ClientRuntimeCommand, + ClientRuntimeResult, +} from '../../../shared/bridge/messages/runtime/index.ts' +import type { ClientRuntimeSessionId } from '../../../shared/bridge/ids.ts' +import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts' +import type { RuntimeCallArgument } from '../../../shared/cdp/index.ts' +import type { ClientRuntimeRouter, ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts' +import type { RuntimeBackend } from '../../../shared/cdp/realm.ts' +import { + clientCompletion, + clientException, + clientHandle, + clientInternalProperty, + clientProperty, +} from './values.ts' +import type { ClientScriptIdentity } from './scripts.ts' + +/** Adapts one connection-local Client Runtime session to the common backend API. */ +export class ClientRuntimeBackend implements RuntimeBackend { + private closed = false + + constructor( + private readonly target: ClientRuntimeTarget, + private readonly sessionId: ClientRuntimeSessionId, + private readonly router: ClientRuntimeRouter, + private readonly scriptIds: ClientScriptIdentity, + ) {} + + enable(): Promise { + return Promise.resolve() + } + + disable(): Promise { + this.router.closeTargetSession(this.target, this.sessionId) + return Promise.resolve() + } + + async evaluate(request: Parameters[0]): ReturnType { + assertClientEvaluationOptions(request) + const { throwOnSideEffect: _throwOnSideEffect, serializationOptions: _serializationOptions, ...supported } = request + return clientCompletion( + expectResult(await this.request({ op: 'evaluate', ...supported }), 'evaluate'), + scriptKey => this.scriptIds.toRuntime(scriptKey), + ) + } + + async getProperties(request: Parameters[0]): ReturnType { + const result = expectResult(await this.request({ + op: 'get-properties', + ...request, + handle: clientHandle(request.handle), + }), 'get-properties') + return { + properties: result.properties.map(clientProperty), + ...(result.internalProperties === undefined + ? {} + : { internalProperties: result.internalProperties.map(clientInternalProperty) }), + ...(result.exceptionDetails === undefined + ? {} + : { + exceptionDetails: clientException( + result.exceptionDetails, + scriptKey => this.scriptIds.toRuntime(scriptKey), + ), + }), + } + } + + async callFunction(request: Parameters[0]): ReturnType { + assertClientCallOptions(request) + const { + receiver, + arguments: args, + throwOnSideEffect: _throwOnSideEffect, + serializationOptions: _serializationOptions, + ...options + } = request + const command: Extract = { + op: 'call-function', + ...options, + ...(receiver === undefined ? {} : { receiver: clientHandle(receiver) }), + ...(args === undefined ? {} : { arguments: args.map(argumentToClient) }), + } + return clientCompletion( + expectResult(await this.request(command), 'call-function'), + scriptKey => this.scriptIds.toRuntime(scriptKey), + ) + } + + async awaitPromise(request: Parameters[0]): ReturnType { + return clientCompletion( + expectResult(await this.request({ + op: 'await-promise', + ...request, + promise: clientHandle(request.promise), + }), 'await-promise'), + scriptKey => this.scriptIds.toRuntime(scriptKey), + ) + } + + async globalLexicalScopeNames(): Promise { + return expectResult(await this.request({ op: 'global-lexical-scope-names' }), 'global-lexical-scope-names').names + } + + async releaseObject(handle: RuntimeBackendObjectHandle): Promise { + expectResult(await this.request({ op: 'release-object', handle: clientHandle(handle) }), 'release-object') + } + + async releaseObjectGroup(group: string): Promise { + expectResult(await this.request({ op: 'release-object-group', objectGroup: group }), 'release-object-group') + } + + /** Close this connection's session and reject further requests. */ + close(): void { + if (this.closed) return + this.closed = true + this.router.closeTargetSession(this.target, this.sessionId) + } + + private request(command: ClientRuntimeCommand): Promise { + if (this.closed) return Promise.reject(new Error('Client realm session is closed')) + return this.router.request(this.target, this.sessionId, command) + } +} + +function argumentToClient(value: RuntimeCallArgument): ClientCallArgument { + return value.kind === 'object' ? { kind: 'object', handle: clientHandle(value.handle) } : value +} + +function expectResult( + result: ClientRuntimeResult, + operation: Operation, +): Extract { + if (result.op !== operation) throw new Error(`Client Runtime returned ${result.op} for ${operation}`) + return result as Extract +} + +function assertClientEvaluationOptions(request: Parameters[0]): void { + if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect') + if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions') + if (request.disableBreaks === true) throw new Error('Client Runtime does not support disableBreaks') + if (request.replMode === true) throw new Error('Client Runtime does not support replMode') + if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture') + if (request.allowUnsafeEvalBlockedByCSP === true) { + throw new Error('Client Runtime cannot bypass the page Content Security Policy') + } + if (request.timeoutMs !== undefined && request.awaitPromise !== true) { + throw new Error('Client Runtime supports timeout only when awaitPromise is enabled') + } +} + +function assertClientCallOptions(request: Parameters[0]): void { + if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect') + if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions') + if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture') +} diff --git a/packages/experimental/inspector/src/worker/realms/client/scripts.ts b/packages/experimental/inspector/src/worker/realms/client/scripts.ts new file mode 100644 index 0000000000..5e0df8adb9 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/scripts.ts @@ -0,0 +1,27 @@ +/** Realm-stable translation between Client catalog keys and common Runtime script keys. */ + +import { inspectorId } from '../../../shared/identity.ts' +import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts' + +/** Allocates one shared script identity namespace for all backends in a Client realm. */ +export class ClientScriptIdentity { + private readonly publicByLocal = new Map() + + constructor(private readonly contextId: number) {} + + /** + * Convert a Client-local key to the realm's public Runtime script key. + * @param localKey - Script key used on the Client wire. + * @returns Stable key shared by this realm's Runtime, Console, and Sources backends. + */ + toRuntime(localKey: RuntimeScriptKey): RuntimeScriptKey { + let scriptKey = this.publicByLocal.get(localKey) + if (scriptKey !== undefined) return scriptKey + scriptKey = inspectorId<'RuntimeScriptKey'>( + `client:${String(Math.abs(this.contextId))}:${String(this.publicByLocal.size + 1)}`, + 'scriptKey', + ) + this.publicByLocal.set(localKey, scriptKey) + return scriptKey + } +} diff --git a/packages/experimental/inspector/src/worker/realms/client/sources.ts b/packages/experimental/inspector/src/worker/realms/client/sources.ts new file mode 100644 index 0000000000..1d89c52eeb --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/sources.ts @@ -0,0 +1,122 @@ +/** Client SourceBackend over the bounded browser source-catalog transport. */ + +import type { ClientScriptDescriptor, ClientSourceResult } from '../../../shared/bridge/messages/sources/index.ts' +import type { ClientSourceSessionId } from '../../../shared/bridge/ids.ts' +import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts' +import type { RuntimeScript } from '../../../shared/cdp/index.ts' +import type { ClientRuntimeTarget } from '../../bridge/runtime-rpc.ts' +import type { ClientSourceRouter } from '../../bridge/source-rpc.ts' +import type { SourceBackend } from '../../../shared/cdp/realm.ts' +import type { ClientScriptIdentity } from './scripts.ts' + +interface ClientScriptRoute { + readonly localKey: RuntimeScriptKey +} + +/** Presents one Client bundle catalog through the common read-only source model. */ +export class ClientSourceBackend implements SourceBackend { + private readonly scripts = new Map() + private catalog: Promise | undefined + private closed = false + + constructor( + private readonly target: ClientRuntimeTarget, + private readonly sessionId: ClientSourceSessionId, + private readonly router: ClientSourceRouter, + private readonly scriptIds: ClientScriptIdentity, + ) {} + + async listScripts(): Promise { + if (this.closed) throw new Error('Client source session is closed') + this.catalog ??= this.loadCatalog() + return this.catalog + } + + async getScriptSource(scriptKey: RuntimeScriptKey): Promise { + const route = await this.route(scriptKey) + const source = await this.read(route.localKey, 'source') + if (source === undefined) throw new Error('Client script source is unavailable') + return source + } + + async getSourceMap(scriptKey: RuntimeScriptKey): Promise { + const route = await this.route(scriptKey) + return this.read(route.localKey, 'source-map') + } + + subscribe(_listener: (script: RuntimeScript) => void): () => void { + return () => {} + } + + /** Reject pending reads owned by this DevTools connection. */ + close(): void { + if (this.closed) return + this.closed = true + this.router.closeSession(this.target.source, this.sessionId) + this.scripts.clear() + } + + private async loadCatalog(): Promise { + const result = expectResult(await this.router.request( + this.target.source, + this.sessionId, + { op: 'list-scripts' }, + ), 'list-scripts') + return result.scripts.map(script => this.register(script)) + } + + private register(script: ClientScriptDescriptor): RuntimeScript { + const scriptKey = this.scriptIds.toRuntime(script.scriptKey) + const descriptor: RuntimeScript = { + ...script, + scriptKey, + executionContextId: this.target.contextId, + } + this.scripts.set(scriptKey, { localKey: script.scriptKey }) + return descriptor + } + + private async route(scriptKey: RuntimeScriptKey): Promise { + await this.listScripts() + const route = this.scripts.get(scriptKey) + if (route === undefined) throw new Error('Client script is no longer available') + return route + } + + private async read( + scriptKey: RuntimeScriptKey, + content: 'source' | 'source-map', + ): Promise { + const chunks: Uint8Array[] = [] + let offset = 0 + while (true) { + const result = expectResult(await this.router.request(this.target.source, this.sessionId, { + op: 'get-content-chunk', + scriptKey, + content, + offset, + maxBytes: this.router.chunkBytes, + }), 'get-content-chunk') + if (!result.available) return undefined + const bytes = Buffer.from(result.data, 'base64') + if (bytes.byteLength > this.router.chunkBytes + || result.nextOffset !== offset + bytes.byteLength + || (!result.eof && result.nextOffset === offset) + || result.nextOffset > this.router.maxContentBytes) { + throw new Error('Client source returned an invalid content chunk') + } + chunks.push(bytes) + offset = result.nextOffset + if (result.eof) break + } + return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)) + } +} + +function expectResult( + result: ClientSourceResult, + operation: Operation, +): Extract { + if (result.op !== operation) throw new Error(`Client source returned ${result.op} for ${operation}`) + return result as Extract +} diff --git a/packages/experimental/inspector/src/worker/realms/client/values.ts b/packages/experimental/inspector/src/worker/realms/client/values.ts new file mode 100644 index 0000000000..f39b52127c --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/client/values.ts @@ -0,0 +1,162 @@ +/** Conversion from Client wire values to realm-neutral Runtime values. */ + +import type { + ClientRuntimeExceptionDetails, + ClientRuntimePropertyDescriptor, + ClientRuntimeRemoteObject, + ClientRuntimeResult, +} from '../../../shared/bridge/messages/runtime/index.ts' +import { + type ClientRemoteObjectHandle, +} from '../../../shared/bridge/ids.ts' +import { inspectorId } from '../../../shared/identity.ts' +import type { RuntimeBackendObjectHandle, RuntimeScriptKey } from '../../../shared/cdp/ids.ts' +import type { + RuntimeCompletion, + RuntimeConsoleBackendEvent, + RuntimeExceptionDetails, + RuntimeInternalPropertyDescriptor, + RuntimePropertyDescriptor, + RuntimeRemoteObject, + RuntimeStackTrace, +} from '../../../shared/cdp/index.ts' + +/** Maps a Client-local script key into its realm-wide Runtime identity. */ +export type ClientScriptKeyMapper = (scriptKey: RuntimeScriptKey) => RuntimeScriptKey + +/** + * Convert one Client completion and all nested objects. + * @param result - Successful Client Runtime command result. + * @param mapScriptKey - Realm-wide script identity mapper. + * @returns A realm-neutral Runtime completion. + */ +export function clientCompletion( + result: Extract, + mapScriptKey: ClientScriptKeyMapper, +): RuntimeCompletion { + return { + result: clientRemoteObject(result.completion.result), + ...(result.completion.exceptionDetails === undefined + ? {} + : { exceptionDetails: clientException(result.completion.exceptionDetails, mapScriptKey) }), + } +} + +/** + * Convert one Client property descriptor and all nested objects. + * @param value - Client wire property descriptor. + * @returns A realm-neutral property descriptor. + */ +export function clientProperty( + value: ClientRuntimePropertyDescriptor, +): RuntimePropertyDescriptor { + const { value: propertyValue, get, set, symbol, ...descriptor } = value + return { + ...descriptor, + ...(propertyValue === undefined ? {} : { value: clientRemoteObject(propertyValue) }), + ...(get === undefined ? {} : { get: clientRemoteObject(get) }), + ...(set === undefined ? {} : { set: clientRemoteObject(set) }), + ...(symbol === undefined ? {} : { symbol: clientRemoteObject(symbol) }), + } +} + +/** + * Convert one Client internal property descriptor. + * @param value - Client wire internal property. + * @returns A realm-neutral internal property. + */ +export function clientInternalProperty( + value: RuntimeInternalPropertyDescriptor, +): RuntimeInternalPropertyDescriptor { + return { + name: value.name, + ...(value.value === undefined ? {} : { value: clientRemoteObject(value.value) }), + } +} + +/** + * Convert Client exception details and their optional object. + * @param value - Client wire exception details. + * @param mapScriptKey - Realm-wide script identity mapper. + * @returns Realm-neutral exception details. + */ +export function clientException( + value: ClientRuntimeExceptionDetails, + mapScriptKey: ClientScriptKeyMapper, +): RuntimeExceptionDetails { + const { exception, ...details } = value + return { + ...details, + ...(value.stackTrace === undefined ? {} : { stackTrace: clientStackTrace(value.stackTrace, mapScriptKey) }), + ...(exception === undefined ? {} : { exception: clientRemoteObject(exception) }), + } +} + +/** + * Convert a Client Console event recursively. + * @param value - Client wire Console event. + * @param mapScriptKey - Realm-wide script identity mapper. + * @returns A realm-neutral Console event. + */ +export function clientConsoleEvent( + value: RuntimeConsoleBackendEvent, + mapScriptKey: ClientScriptKeyMapper, +): RuntimeConsoleBackendEvent { + if (value.type === 'console-api') { + return { + type: value.type, + event: { + ...value.event, + arguments: value.event.arguments.map(clientRemoteObject), + ...(value.event.stackTrace === undefined + ? {} + : { stackTrace: clientStackTrace(value.event.stackTrace, mapScriptKey) }), + }, + } + } + return { + type: value.type, + event: { ...value.event, details: clientException(value.event.details, mapScriptKey) }, + } +} + +/** + * Convert a Client RemoteObject into the backend-neutral handle slot. + * @param value - Client wire RemoteObject. + * @returns A realm-neutral Runtime value. + */ +export function clientRemoteObject( + value: ClientRuntimeRemoteObject, +): RuntimeRemoteObject { + return { + descriptor: value.descriptor, + ...(value.object === undefined + ? {} + : { object: { handle: backendHandle(value.object.handle) } }), + ...(value.semanticReference === undefined ? {} : { semanticReference: value.semanticReference }), + } +} + +/** + * Rebrand a common backend handle for the Client transport that owns it. + * @param value - Backend handle from a routed Runtime request. + * @returns The same opaque text under its Client wire role. + */ +export function clientHandle(value: string): ClientRemoteObjectHandle { + return inspectorId<'ClientRemoteObjectHandle'>(value, 'Client object handle') +} + +function backendHandle(value: string): RuntimeBackendObjectHandle { + return inspectorId<'RuntimeBackendObjectHandle'>(value, 'Runtime backend object handle') +} + +function clientStackTrace(value: RuntimeStackTrace, mapScriptKey: ClientScriptKeyMapper): RuntimeStackTrace { + return { + ...value, + callFrames: value.callFrames.map(frame => ({ + ...frame, + ...(frame.scriptKey === undefined ? {} : { scriptKey: mapScriptKey(frame.scriptKey) }), + })), + ...(value.parent === undefined ? {} : { parent: clientStackTrace(value.parent, mapScriptKey) }), + } +} diff --git a/packages/experimental/inspector/src/worker/realms/host/bridge.ts b/packages/experimental/inspector/src/worker/realms/host/bridge.ts new file mode 100644 index 0000000000..4a9955c1e0 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/bridge.ts @@ -0,0 +1,164 @@ +/** Per-DevTools-connection bridge to the Host main thread's real V8 inspector target. */ + +import { Session } from 'node:inspector' +import type { NativeProtocolNotification } from '../../../shared/cdp/realm.ts' + +/** Notification emitted by Node's native inspector session. */ +export type HostInspectorNotification = NativeProtocolNotification + +interface DynamicInspectorSession { + connectToMainThread(): void + disconnect(): void + on(event: 'inspectorNotification', listener: (message: HostInspectorNotification) => void): this + post( + method: string, + params: Readonly> | undefined, + callback: (error: Error | null, result?: Readonly>) => void, + ): void +} + +/** Connection-local carrier for requests and notifications from the Host V8 inspector. */ +export class HostInspectorSession { + private readonly session = new Session() as unknown as DynamicInspectorSession + private readonly listeners = new Set<(message: HostInspectorNotification) => void>() + private connected = false + private failure: string | undefined + + constructor(private readonly contextName: string) { + this.session.on('inspectorNotification', (message) => { + const rewritten = this.rewriteContextName(message) + for (const listener of [...this.listeners]) { + try { + listener(rewritten) + } catch { + // One domain subscriber cannot starve notifications for sibling domains. + } + } + }) + } + + /** + * Subscribe to native inspector notifications. + * @param listener - Consumer owned by one Worker domain adapter. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (message: HostInspectorNotification) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Execute one Host V8 request for a Worker-owned composite Runtime operation. + * @param method - CDP method name. + * @param params - Validated request parameters. + * @returns The Host inspector result. + */ + request(method: string, params: Readonly>): Promise>> { + const failure = this.connect() + if (failure !== undefined) return Promise.reject(new Error(failure)) + return new Promise((resolve, reject) => { + try { + this.session.post(method, params, (error, result) => { + if (error !== null) reject(error) + else resolve(result ?? {}) + }) + } catch (error) { + reject(new Error(renderError(error))) + } + }) + } + + /** Disconnect this DevTools client's V8 session. */ + close(): void { + this.listeners.clear() + if (!this.connected || this.failure !== undefined) return + this.connected = false + try { + this.session.disconnect() + } catch { + // The underlying inspector session is already disconnected. + } + } + + private connect(): string | undefined { + if (this.connected) return this.failure + this.connected = true + try { + this.session.connectToMainThread() + } catch (error) { + this.failure = `Host V8 inspector is unavailable: ${renderError(error)}` + } + return this.failure + } + + private rewriteContextName(message: HostInspectorNotification): HostInspectorNotification { + if (message.method !== 'Runtime.executionContextCreated') return message + const params = message.params + const context = params?.context + if (typeof context !== 'object' || context === null) return message + const record = context as Readonly> + const auxData = record.auxData + if (typeof auxData !== 'object' || auxData === null || (auxData as Readonly>).isDefault !== true) { + return message + } + return { + method: message.method, + params: { + ...params, + context: { ...record, name: this.contextName }, + }, + } + } +} + +/** Serializes accepted native notifications and isolates sibling consumers. */ +export class HostNotificationChannel { + private readonly listeners = new Set<(event: Event) => void>() + private readonly unsubscribe: () => void + private delivery = Promise.resolve() + + constructor( + target: HostInspectorSession, + private readonly accepts: (message: HostInspectorNotification) => boolean, + private readonly project: (message: HostInspectorNotification) => Promise, + ) { + this.unsubscribe = target.subscribe((message) => { this.receive(message) }) + } + + /** + * Subscribe to projected native notifications. + * @param listener - Consumer invoked in subscription order. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (event: Event) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Release the native notification subscription and all consumers. */ + close(): void { + this.unsubscribe() + this.listeners.clear() + } + + private receive(message: HostInspectorNotification): void { + if (!this.accepts(message)) return + this.delivery = this.delivery.then(async () => { + const event = await this.project(message) + if (event === undefined) return + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One notification consumer cannot prevent delivery to its siblings. + } + } + }).catch(() => { + // Malformed optional native notifications do not interrupt request handling. + }) + } +} + +function renderError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/experimental/inspector/src/worker/realms/host/console.ts b/packages/experimental/inspector/src/worker/realms/host/console.ts new file mode 100644 index 0000000000..0c6574ebd1 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/console.ts @@ -0,0 +1,90 @@ +/** ConsoleBackend implementation over native Node Runtime notifications. */ + +import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts' +import type { + RuntimeConsoleBackendEvent, + RuntimeConsoleType, +} from '../../../shared/cdp/index.ts' +import type { HostInspectorSession } from './bridge.ts' +import type { ConsoleBackend } from '../../../shared/cdp/realm.ts' +import { isNativeRecord } from './values.ts' +import { HostNotificationChannel } from './bridge.ts' +import type { HostRuntimeBackend } from './runtime.ts' + +const CONSOLE_TYPES = new Set([ + 'log', 'debug', 'info', 'error', 'warning', 'dir', 'dirxml', 'table', 'trace', 'clear', + 'startGroup', 'startGroupCollapsed', 'endGroup', 'assert', 'profile', 'profileEnd', 'count', 'timeEnd', +]) + +/** Converts native Runtime notifications to realm-neutral Console events. */ +export class HostConsoleBackend implements ConsoleBackend { + private readonly events: HostNotificationChannel> + + constructor( + private readonly target: HostInspectorSession, + private readonly runtime: HostRuntimeBackend, + ) { + this.events = new HostNotificationChannel( + target, + message => message.method === 'Runtime.consoleAPICalled' || message.method === 'Runtime.exceptionThrown', + async message => message.method === 'Runtime.consoleAPICalled' + ? this.consoleEvent(message.params) + : this.exceptionEvent(message.params), + ) + } + + /** + * Subscribe to native Console and exception events. + * @param listener - Connection-local event consumer. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (event: RuntimeConsoleBackendEvent) => void): () => void { + return this.events.subscribe(listener) + } + + async clear(): Promise { + await this.target.request('Runtime.discardConsoleEntries', {}) + } + + /** Release the native notification subscription. */ + close(): void { + this.events.close() + } + + private async consoleEvent( + params: Readonly> | undefined, + ): Promise | undefined> { + const type = params?.type + const args = params?.args + const timestamp = params?.timestamp + const stackTrace = params?.stackTrace + if (!CONSOLE_TYPES.has(type as RuntimeConsoleType) || !Array.isArray(args) || typeof timestamp !== 'number') return undefined + return { + type: 'console-api', + event: { + type: type as RuntimeConsoleType, + arguments: await Promise.all(args.map(value => this.runtime.remoteObject(value))), + timestamp, + ...(typeof params?.executionContextId === 'number' ? { contextId: params.executionContextId } : {}), + ...(isNativeRecord(stackTrace) ? { stackTrace: this.runtime.stackTrace(stackTrace) } : {}), + }, + } + } + + private async exceptionEvent( + params: Readonly> | undefined, + ): Promise | undefined> { + const timestamp = params?.timestamp + const exceptionDetails = params?.exceptionDetails + const contextId = params?.executionContextId + if (typeof timestamp !== 'number' || exceptionDetails === undefined) return undefined + return { + type: 'exception', + event: { + timestamp, + ...(typeof contextId === 'number' ? { contextId } : {}), + details: await this.runtime.exceptionDetails(exceptionDetails), + }, + } + } +} diff --git a/packages/experimental/inspector/src/worker/realms/host/debugger.ts b/packages/experimental/inspector/src/worker/realms/host/debugger.ts new file mode 100644 index 0000000000..95a9f83184 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/debugger.ts @@ -0,0 +1,167 @@ +/** DebuggerBackend implementation over one native Node inspector session. */ + +import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts' +import { isJsonValue } from '../../../shared/json.ts' +import type { + RuntimeDebuggerCallFrame, + RuntimeDebuggerEvent, + RuntimeDebuggerLocation, + RuntimeDebuggerScope, +} from '../../../shared/cdp/index.ts' +import type { DebuggerBackend } from '../../../shared/cdp/realm.ts' +import type { HostInspectorSession } from './bridge.ts' +import { optionalNativeField, requireNativeRecord } from './values.ts' +import { HostNotificationChannel } from './bridge.ts' +import type { HostRuntimeBackend } from './runtime.ts' +import { hostScriptKey } from './scripts.ts' + +/** Native Host debugger adapted to common commands, Runtime values, and events. */ +export class HostDebuggerBackend implements DebuggerBackend { + private readonly events: HostNotificationChannel> + + constructor( + private readonly target: HostInspectorSession, + private readonly runtime: HostRuntimeBackend, + ) { + this.events = new HostNotificationChannel( + target, + message => message.method === 'Debugger.resumed' + || message.method === 'Debugger.breakpointResolved' + || message.method === 'Debugger.paused', + async message => message.method === 'Debugger.resumed' + ? { type: 'resumed' } + : message.method === 'Debugger.breakpointResolved' + ? breakpointResolved(message.params) + : this.paused(message.params), + ) + } + + async enable(request: Parameters[0]): Promise>> { + return this.target.request('Debugger.enable', { + ...optionalNativeField('maxScriptsCacheSize', request.maxScriptsCacheSize), + }) + } + + async disable(): Promise>> { + return this.target.request('Debugger.disable', {}) + } + + async pause(): Promise>> { + return this.target.request('Debugger.pause', {}) + } + + async resume(request: Parameters[0]): Promise>> { + return this.target.request('Debugger.resume', { + ...optionalNativeField('terminateOnResume', request.terminateOnResume), + }) + } + + async evaluateOnCallFrame( + request: Parameters[0], + ): ReturnType { + return this.runtime.completion(await this.target.request('Debugger.evaluateOnCallFrame', { + callFrameId: request.callFrameId, + expression: request.expression, + ...optionalNativeField('objectGroup', request.objectGroup), + ...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI), + ...optionalNativeField('silent', request.silent), + ...optionalNativeField('returnByValue', request.returnByValue), + ...optionalNativeField('generatePreview', request.generatePreview), + ...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect), + ...optionalNativeField('timeout', request.timeoutMs), + })) + } + + subscribe(listener: (event: RuntimeDebuggerEvent) => void): () => void { + return this.events.subscribe(listener) + } + + /** Release the native notification subscription. */ + close(): void { + this.events.close() + } + + private async paused( + params: Readonly> | undefined, + ): Promise | undefined> { + if (!Array.isArray(params?.callFrames) || typeof params.reason !== 'string') return undefined + const callFrames = await Promise.all(params.callFrames.map(async frame => this.callFrame(frame))) + const data = params.data + const hitBreakpoints = params.hitBreakpoints + return { + type: 'paused', + callFrames, + reason: params.reason, + ...(data === undefined || !isJsonValue(data) ? {} : { data }), + ...(isStringArray(hitBreakpoints) + ? { hitBreakpoints: hitBreakpoints } + : {}), + ...(params.asyncStackTrace === undefined + ? {} + : { asyncStackTrace: this.runtime.stackTrace(params.asyncStackTrace) }), + } + } + + private async callFrame(value: unknown): Promise> { + const record = requireNativeRecord(value, 'Host Debugger call frame') + if (typeof record.callFrameId !== 'string' + || typeof record.functionName !== 'string' + || typeof record.url !== 'string' + || !Array.isArray(record.scopeChain)) { + throw new Error('Host Debugger returned an invalid call frame') + } + return { + callFrameId: record.callFrameId, + functionName: record.functionName, + ...(record.functionLocation === undefined ? {} : { functionLocation: location(record.functionLocation) }), + location: location(record.location), + url: record.url, + scopeChain: await Promise.all(record.scopeChain.map(async scope => this.scope(scope))), + thisObject: await this.runtime.remoteObject(record.this), + ...(record.returnValue === undefined ? {} : { returnValue: await this.runtime.remoteObject(record.returnValue) }), + } + } + + private async scope(value: unknown): Promise> { + const record = requireNativeRecord(value, 'Host Debugger scope') + if (typeof record.type !== 'string') throw new Error('Host Debugger returned an invalid scope') + return { + type: record.type, + object: await this.runtime.remoteObject(record.object), + ...(typeof record.name === 'string' ? { name: record.name } : {}), + ...(record.startLocation === undefined ? {} : { startLocation: location(record.startLocation) }), + ...(record.endLocation === undefined ? {} : { endLocation: location(record.endLocation) }), + } + } + +} + +function breakpointResolved( + params: Readonly> | undefined, +): Extract, { type: 'breakpoint-resolved' }> | undefined { + if (typeof params?.breakpointId !== 'string' || params.location === undefined) return undefined + return { + type: 'breakpoint-resolved', + breakpointId: params.breakpointId, + location: location(params.location), + } +} + +function location(value: unknown): RuntimeDebuggerLocation { + const record = requireNativeRecord(value, 'Host Debugger location') + if (typeof record.scriptId !== 'string' || !Number.isSafeInteger(record.lineNumber)) { + throw new Error('Host Debugger returned an invalid location') + } + if (record.columnNumber !== undefined && !Number.isSafeInteger(record.columnNumber)) { + throw new Error('Host Debugger returned an invalid location column') + } + return { + scriptKey: hostScriptKey(record.scriptId), + lineNumber: record.lineNumber as number, + ...(record.columnNumber === undefined ? {} : { columnNumber: record.columnNumber as number }), + } +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === 'string') +} diff --git a/packages/experimental/inspector/src/worker/realms/host/index.ts b/packages/experimental/inspector/src/worker/realms/host/index.ts new file mode 100644 index 0000000000..7c8f7164d1 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/index.ts @@ -0,0 +1,67 @@ +/** Host realm adapter backed by a connection-local Node inspector session. */ + +import { randomUUID } from 'node:crypto' +import { inspectorId } from '../../../shared/identity.ts' +import { HostConsoleBackend } from './console.ts' +import { HostDebuggerBackend } from './debugger.ts' +import { HostRuntimeBackend } from './runtime.ts' +import { HostSourceBackend } from './sources.ts' +import { HostInspectorSession } from './bridge.ts' +import type { InspectorRealm, InspectorRealmDescriptor, InspectorRealmSession } from '../../inspection/realm.ts' + +const HOST_RUNTIME_OPERATIONS = [ + 'evaluate', + 'get-properties', + 'call-function', + 'await-promise', + 'release-object', + 'release-object-group', + 'global-lexical-scope-names', +] as const + +/** Host realm definition that opens one native V8 session per DevTools connection. */ +export class HostInspectorRealm implements InspectorRealm { + readonly descriptor: InspectorRealmDescriptor + readonly context: InspectorRealm['context'] = { kind: 'native' } + readonly capabilities: InspectorRealm['capabilities'] = { + runtime: HOST_RUNTIME_OPERATIONS, + console: ['events', 'exceptions', 'clear'], + sources: ['catalog', 'content', 'source-map'], + debugger: ['breakpoint', 'pause', 'resume', 'step', 'call-frame'], + } + + constructor(private readonly label: string) { + this.descriptor = { + realmId: inspectorId<'InspectorRealmId'>(randomUUID(), 'realmId'), + sourceId: inspectorId<'InspectorSourceId'>('host-runtime', 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>(randomUUID(), 'generation'), + kind: 'host', + label, + } + } + + /** Open a native Host inspector session for one DevTools connection. */ + openSession(): InspectorRealmSession { + const target = new HostInspectorSession(this.label) + const runtime = new HostRuntimeBackend(target) + const console = new HostConsoleBackend(target, runtime) + const sources = new HostSourceBackend(target) + const debug = new HostDebuggerBackend(target, runtime) + return { + descriptor: this.descriptor, + context: this.context, + runtime: { state: 'supported', backend: runtime }, + console: { state: 'supported', backend: console }, + sources: { state: 'supported', backend: sources }, + debugger: { state: 'supported', backend: debug }, + nativeDomains: { state: 'supported', backend: target }, + close: () => { + sources.close() + debug.close() + console.close() + runtime.close() + target.close() + }, + } + } +} diff --git a/packages/experimental/inspector/src/worker/realms/host/runtime.ts b/packages/experimental/inspector/src/worker/realms/host/runtime.ts new file mode 100644 index 0000000000..bc1bd4d50f --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/runtime.ts @@ -0,0 +1,322 @@ +/** RuntimeBackend implementation over one native Node inspector session. */ + +import { inspectorId } from '../../../shared/identity.ts' +import type { RuntimeBackendObjectHandle } from '../../../shared/cdp/ids.ts' +import { isJsonValue } from '../../../shared/json.ts' +import { IDENTIFY_REALM_OBJECT_FUNCTION } from '../../../shared/cordis/object-registry.ts' +import { parseInspectorObjectReference, type InspectorObjectReference } from '../../../shared/cordis/object-reference.ts' +import type { + RuntimeCallArgument, + RuntimeCompletion, + RuntimeExceptionDetails, + RuntimeInternalPropertyDescriptor, + RuntimePrivatePropertyDescriptor, + RuntimeProperties, + RuntimePropertyDescriptor, + RuntimeRemoteObject, + RuntimeRemoteObjectDescriptor, + RuntimeStackTrace, +} from '../../../shared/cdp/index.ts' +import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts' +import type { RuntimeBackend } from '../../../shared/cdp/realm.ts' +import { isNativeRecord, optionalNativeField, requireNativeRecord } from './values.ts' +import { hostScriptKey } from './scripts.ts' + +/** Host Runtime adapter preserving native V8 semantics behind common values. */ +export class HostRuntimeBackend implements RuntimeBackend { + private defaultContextId: number | undefined + private readonly unsubscribe: () => void + + constructor(private readonly target: HostInspectorSession) { + this.unsubscribe = target.subscribe((message) => { this.observeContext(message) }) + } + + async enable(): Promise { + await this.target.request('Runtime.enable', {}) + } + + async disable(): Promise { + await this.target.request('Runtime.disable', {}) + this.defaultContextId = undefined + } + + async evaluate(request: Parameters[0]): ReturnType { + return this.completion(await this.target.request('Runtime.evaluate', { + expression: request.expression, + ...optionalNativeField('objectGroup', request.objectGroup), + ...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI), + ...optionalNativeField('silent', request.silent), + ...optionalNativeField('returnByValue', request.returnByValue), + ...optionalNativeField('generatePreview', request.generatePreview), + ...optionalNativeField('userGesture', request.userGesture), + ...optionalNativeField('awaitPromise', request.awaitPromise), + ...optionalNativeField('disableBreaks', request.disableBreaks), + ...optionalNativeField('replMode', request.replMode), + ...optionalNativeField('allowUnsafeEvalBlockedByCSP', request.allowUnsafeEvalBlockedByCSP), + ...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect), + ...optionalNativeField('serializationOptions', request.serializationOptions), + ...optionalNativeField('timeout', request.timeoutMs), + })) + } + + async getProperties(request: Parameters[0]): ReturnType { + const response = await this.target.request('Runtime.getProperties', { + objectId: request.handle, + ...optionalNativeField('ownProperties', request.ownProperties), + ...optionalNativeField('accessorPropertiesOnly', request.accessorPropertiesOnly), + ...optionalNativeField('generatePreview', request.generatePreview), + ...optionalNativeField('nonIndexedPropertiesOnly', request.nonIndexedPropertiesOnly), + }) + return this.properties(response) + } + + async callFunction(request: Parameters[0]): ReturnType { + const receiver = request.receiver + const contextId = receiver === undefined ? this.defaultContextId : undefined + if (receiver === undefined && contextId === undefined) { + throw new Error('Host Runtime default execution context is unavailable') + } + return this.completion(await this.target.request('Runtime.callFunctionOn', { + functionDeclaration: request.functionDeclaration, + ...(receiver === undefined ? { executionContextId: contextId } : { objectId: receiver }), + ...(request.arguments === undefined ? {} : { arguments: request.arguments.map(toNativeArgument) }), + ...optionalNativeField('objectGroup', request.objectGroup), + ...optionalNativeField('silent', request.silent), + ...optionalNativeField('returnByValue', request.returnByValue), + ...optionalNativeField('generatePreview', request.generatePreview), + ...optionalNativeField('userGesture', request.userGesture), + ...optionalNativeField('awaitPromise', request.awaitPromise), + ...optionalNativeField('throwOnSideEffect', request.throwOnSideEffect), + ...optionalNativeField('serializationOptions', request.serializationOptions), + })) + } + + async awaitPromise(request: Parameters[0]): ReturnType { + return this.completion(await this.target.request('Runtime.awaitPromise', { + promiseObjectId: request.promise, + ...optionalNativeField('returnByValue', request.returnByValue), + ...optionalNativeField('generatePreview', request.generatePreview), + })) + } + + async globalLexicalScopeNames(): Promise { + const response = await this.target.request('Runtime.globalLexicalScopeNames', { + ...optionalNativeField('executionContextId', this.defaultContextId), + }) + if (!Array.isArray(response.names) || !response.names.every(name => typeof name === 'string')) { + throw new Error('Host Runtime returned invalid lexical scope names') + } + return response.names + } + + async releaseObject(handle: RuntimeBackendObjectHandle): Promise { + await this.target.request('Runtime.releaseObject', { objectId: handle }) + } + + async releaseObjectGroup(group: string): Promise { + await this.target.request('Runtime.releaseObjectGroup', { objectGroup: group }) + } + + /** Release the native-context observer owned by this backend. */ + close(): void { + this.unsubscribe() + } + + /** + * Convert a native Runtime completion returned through another Node domain. + * @param value - Native result and optional exception details. + * @returns The realm-neutral completion. + */ + async completion(value: Readonly>): Promise> { + return { + result: await this.remoteObject(value.result), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }), + } + } + + private async properties(value: Readonly>): Promise> { + if (!Array.isArray(value.result)) throw new Error('Host Runtime returned invalid properties') + return { + properties: await Promise.all(value.result.map(item => this.property(item))), + ...(value.internalProperties === undefined + ? {} + : { internalProperties: await this.internalProperties(value.internalProperties) }), + ...(value.privateProperties === undefined + ? {} + : { privateProperties: await this.privateProperties(value.privateProperties) }), + ...(value.exceptionDetails === undefined + ? {} + : { exceptionDetails: await this.exceptionDetails(value.exceptionDetails) }), + } + } + + private async property(value: unknown): Promise> { + const record = requireNativeRecord(value, 'Host Runtime property descriptor') + if (typeof record.name !== 'string' + || typeof record.configurable !== 'boolean' + || typeof record.enumerable !== 'boolean') { + throw new Error('Host Runtime returned invalid property descriptor') + } + return { + ...record, + name: record.name, + configurable: record.configurable, + enumerable: record.enumerable, + ...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }), + ...(record.get === undefined ? {} : { get: await this.remoteObject(record.get) }), + ...(record.set === undefined ? {} : { set: await this.remoteObject(record.set) }), + ...(record.symbol === undefined ? {} : { symbol: await this.remoteObject(record.symbol) }), + } + } + + private async internalProperties(value: unknown): Promise[]> { + if (!Array.isArray(value)) throw new Error('Host Runtime returned invalid internal properties') + return Promise.all(value.map(async (item) => { + const record = requireNativeRecord(item, 'Host Runtime internal property') + if (typeof record.name !== 'string') throw new Error('Host Runtime returned invalid internal property') + return { + name: record.name, + ...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }), + } + })) + } + + private async privateProperties(value: unknown): Promise[]> { + if (!Array.isArray(value)) throw new Error('Host Runtime returned invalid private properties') + return Promise.all(value.map(async (item) => { + const record = requireNativeRecord(item, 'Host Runtime private property') + if (typeof record.name !== 'string') throw new Error('Host Runtime returned invalid private property') + return { + name: record.name, + ...(record.value === undefined ? {} : { value: await this.remoteObject(record.value) }), + ...(record.get === undefined ? {} : { get: await this.remoteObject(record.get) }), + ...(record.set === undefined ? {} : { set: await this.remoteObject(record.set) }), + } + })) + } + + /** + * Convert native exception details to the common Runtime model. + * @param value - Native `Runtime.ExceptionDetails` fields. + * @returns Exception details with normalized object references. + */ + async exceptionDetails(value: unknown): Promise> { + const record = requireNativeRecord(value, 'Host Runtime exception details') + if (typeof record.text !== 'string' + || !Number.isSafeInteger(record.lineNumber) + || !Number.isSafeInteger(record.columnNumber)) { + throw new Error('Host Runtime returned invalid exception details') + } + return { + ...record, + text: record.text, + lineNumber: record.lineNumber as number, + columnNumber: record.columnNumber as number, + ...(record.stackTrace === undefined ? {} : { stackTrace: this.stackTrace(record.stackTrace) }), + ...(record.exception === undefined ? {} : { exception: await this.remoteObject(record.exception) }), + } + } + + /** + * Convert one native V8 RemoteObject to the common Runtime model. + * @param value - Native `Runtime.RemoteObject` fields. + * @returns Descriptor, backend handle, and optional Cordis identity. + */ + async remoteObject(value: unknown): Promise> { + const record = requireNativeRecord(value, 'Host Runtime RemoteObject') + if (typeof record.type !== 'string') throw new Error('Host Runtime returned an invalid RemoteObject') + const descriptor = { ...record } + Reflect.deleteProperty(descriptor, 'objectId') + if (!isJsonValue(descriptor)) throw new Error('Host Runtime returned a non-JSON RemoteObject descriptor') + const objectId = typeof record.objectId === 'string' ? record.objectId : undefined + const semanticReference = objectId === undefined ? undefined : await this.identifyObject(objectId) + return { + descriptor: descriptor as unknown as RuntimeRemoteObjectDescriptor, + ...(objectId === undefined ? {} : { object: { handle: backendHandle(objectId) } }), + ...(semanticReference === undefined ? {} : { semanticReference }), + } + } + + /** + * Convert a native stack trace while retaining native script identities. + * @param value - Native `Runtime.StackTrace` fields. + * @returns Realm-neutral stack frames. + */ + stackTrace(value: unknown): RuntimeStackTrace { + const record = requireNativeRecord(value, 'Host Runtime stack trace') + if (!Array.isArray(record.callFrames)) throw new Error('Host Runtime returned an invalid stack trace') + return { + ...(typeof record.description === 'string' ? { description: record.description } : {}), + callFrames: record.callFrames.map((frame) => { + const fields = requireNativeRecord(frame, 'Host Runtime call frame') + if (typeof fields.functionName !== 'string' + || typeof fields.url !== 'string' + || !Number.isSafeInteger(fields.lineNumber) + || !Number.isSafeInteger(fields.columnNumber)) { + throw new Error('Host Runtime returned an invalid call frame') + } + return { + functionName: fields.functionName, + ...(typeof fields.scriptId === 'string' + ? { scriptKey: hostScriptKey(fields.scriptId) } + : {}), + url: fields.url, + lineNumber: fields.lineNumber as number, + columnNumber: fields.columnNumber as number, + } + }), + ...(record.parent === undefined ? {} : { parent: this.stackTrace(record.parent) }), + } + } + + private observeContext(message: HostInspectorNotification): void { + if (message.method === 'Runtime.executionContextCreated') { + const context = isNativeRecord(message.params?.context) ? message.params.context : undefined + const auxData = isNativeRecord(context?.auxData) ? context.auxData : undefined + if (context !== undefined && auxData?.isDefault === true && Number.isSafeInteger(context.id)) { + this.defaultContextId = context.id as number + } + return + } + if (message.method !== 'Runtime.executionContextDestroyed') return + if (message.params?.executionContextId === this.defaultContextId) this.defaultContextId = undefined + } + + private async identifyObject(objectId: string): Promise { + try { + const response = await this.target.request('Runtime.callFunctionOn', { + objectId, + functionDeclaration: IDENTIFY_REALM_OBJECT_FUNCTION, + returnByValue: true, + silent: true, + }) + if (response.exceptionDetails !== undefined || !isNativeRecord(response.result)) return undefined + return response.result.value === undefined + ? undefined + : parseInspectorObjectReference(response.result.value) + } catch { + // Semantic recognition is optional metadata; preserve the Runtime value on failure. + return undefined + } + } +} + +function toNativeArgument(value: RuntimeCallArgument): Readonly> { + switch (value.kind) { + case 'value': return { value: value.value } + case 'unserializable': return { unserializableValue: value.value } + case 'object': return { objectId: value.handle } + case 'undefined': return {} + default: return assertNever(value) + } +} + +function backendHandle(value: string): RuntimeBackendObjectHandle { + return inspectorId<'RuntimeBackendObjectHandle'>(value, 'Runtime backend object handle') +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Runtime call argument: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/worker/realms/host/scripts.ts b/packages/experimental/inspector/src/worker/realms/host/scripts.ts new file mode 100644 index 0000000000..25e0549f9b --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/scripts.ts @@ -0,0 +1,13 @@ +/** Host-native script identity conversion for normalized source and debugger values. */ + +import { inspectorId } from '../../../shared/identity.ts' +import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts' + +/** + * Convert a Node inspector script id into the realm backend identity namespace. + * @param value - Native Node inspector script id. + * @returns The corresponding normalized script key. + */ +export function hostScriptKey(value: string): RuntimeScriptKey { + return inspectorId<'RuntimeScriptKey'>(value, 'scriptKey') +} diff --git a/packages/experimental/inspector/src/worker/realms/host/sources.ts b/packages/experimental/inspector/src/worker/realms/host/sources.ts new file mode 100644 index 0000000000..d70305efe1 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/sources.ts @@ -0,0 +1,99 @@ +/** SourceBackend implementation over native Node Debugger notifications. */ + +import type { RuntimeScriptKey } from '../../../shared/cdp/ids.ts' +import type { RuntimeScript } from '../../../shared/cdp/index.ts' +import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts' +import type { SourceBackend } from '../../../shared/cdp/realm.ts' +import { hostScriptKey } from './scripts.ts' + +interface HostScript { + readonly descriptor: RuntimeScript + readonly nativeId: string +} + +/** Maintains one connection-local catalog of scripts reported by Node's inspector. */ +export class HostSourceBackend implements SourceBackend { + private readonly scripts = new Map() + private readonly listeners = new Set<(script: RuntimeScript) => void>() + private readonly unsubscribe: () => void + + constructor( + private readonly target: HostInspectorSession, + ) { + this.unsubscribe = target.subscribe((message) => { this.receive(message) }) + } + + listScripts(): Promise { + return Promise.resolve([...this.scripts.values()].map(script => script.descriptor)) + } + + async getScriptSource(scriptKey: RuntimeScriptKey): Promise { + const script = this.scripts.get(scriptKey) + if (script === undefined) throw new Error('Host script is no longer available') + const result = await this.target.request('Debugger.getScriptSource', { scriptId: script.nativeId }) + if (typeof result.scriptSource !== 'string') throw new Error('Host Debugger returned no script source') + return result.scriptSource + } + + getSourceMap(_scriptKey: RuntimeScriptKey): Promise { + return Promise.resolve(undefined) + } + + /** + * Subscribe to scripts discovered after the initial catalog read. + * @param listener - Consumer of newly discovered scripts. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (script: RuntimeScript) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Release the native notification subscription and cached catalog. */ + close(): void { + this.unsubscribe() + this.scripts.clear() + this.listeners.clear() + } + + private receive(message: HostInspectorNotification): void { + if (message.method !== 'Debugger.scriptParsed') return + const params = message.params + if (params === undefined + || typeof params.scriptId !== 'string' + || typeof params.url !== 'string' + || !isInteger(params.startLine) + || !isInteger(params.startColumn) + || !isInteger(params.endLine) + || !isInteger(params.endColumn)) return + const scriptKey = hostScriptKey(params.scriptId) + const descriptor: RuntimeScript = { + scriptKey, + url: params.url, + hash: typeof params.hash === 'string' ? params.hash : '', + ...(typeof params.buildId === 'string' ? { buildId: params.buildId } : {}), + startLine: params.startLine, + startColumn: params.startColumn, + endLine: params.endLine, + endColumn: params.endColumn, + ...(typeof params.sourceMapURL === 'string' && params.sourceMapURL.length > 0 + ? { sourceMapUrl: params.sourceMapURL } + : {}), + ...(isInteger(params.executionContextId) ? { executionContextId: params.executionContextId } : {}), + ...(typeof params.isModule === 'boolean' ? { isModule: params.isModule } : {}), + ...(isInteger(params.length) ? { length: params.length } : {}), + } + this.scripts.set(scriptKey, { descriptor, nativeId: params.scriptId }) + for (const listener of [...this.listeners]) { + try { + listener(descriptor) + } catch { + // One source consumer cannot prevent delivery to sibling consumers. + } + } + } +} + +function isInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0 +} diff --git a/packages/experimental/inspector/src/worker/realms/host/values.ts b/packages/experimental/inspector/src/worker/realms/host/values.ts new file mode 100644 index 0000000000..afd4e5b222 --- /dev/null +++ b/packages/experimental/inspector/src/worker/realms/host/values.ts @@ -0,0 +1,34 @@ +/** Small validators for values returned by Node's native Inspector protocol. */ + +/** + * Test whether a native protocol value is a non-array object record. + * @param value - Native protocol value. + * @returns Whether the value can be read as named fields. + */ +export function isNativeRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Require a native protocol object record. + * @param value - Native protocol value. + * @param label - Subject named in the validation error. + * @returns The validated object record. + */ +export function requireNativeRecord(value: unknown, label: string): Readonly> { + if (!isNativeRecord(value)) throw new Error(`${label} must be an object`) + return value +} + +/** + * Include an optional field only when the native request supplied a value. + * @param key - Native protocol field name. + * @param value - Optional field value. + * @returns An empty record or the requested field. + */ +export function optionalNativeField( + key: Key, + value: Value | undefined, +): Partial> { + return value === undefined ? {} : { [key]: value } as Partial> +} diff --git a/packages/experimental/inspector/src/worker/server.ts b/packages/experimental/inspector/src/worker/server.ts new file mode 100644 index 0000000000..4bebfc3ad7 --- /dev/null +++ b/packages/experimental/inspector/src/worker/server.ts @@ -0,0 +1,111 @@ +/** Inspector Worker assembly over one Host source port and one loopback endpoint. */ + +import type { MessagePort } from 'node:worker_threads' +import type { InspectorWorkerBoot } from '../shared/bridge/messages/control.ts' +import type { WorkerToSourceFrame } from '../shared/bridge/messages/observation.ts' +import { createCordisRuntimeTreeReader } from '../shared/cordis/reader.ts' +import { NetworkDomain } from './cdp/domains/network/session.ts' +import { NetworkStore } from './inspection/network-store.ts' +import { CordisDomBackend } from './cdp/domains/dom/index.ts' +import { ClientRuntimeRouter } from './bridge/runtime-rpc.ts' +import { ClientSourceRouter } from './bridge/source-rpc.ts' +import { CordisTreeStore } from './inspection/cordis-store.ts' +import { InspectorEndpoint, type InspectorEndpointInfo } from './bridge/endpoint.ts' +import { InspectorQueryRouter } from './inspection/query-router.ts' +import { InspectorRealmRegistry } from './inspection/realm-store.ts' +import { HostInspectorRealm } from './realms/host/index.ts' +import { InspectorSourceRegistry, type SourceConnection } from './bridge/hub.ts' + +/** Live Worker runtime. */ +export interface InspectorWorkerRuntime { + readonly endpoint: InspectorEndpointInfo + close(): Promise +} + +/** + * Assemble and start the Worker-owned source registry, Runtime router, Network domain, and endpoints. + * @param boot - Validated Worker configuration and transferred Host source port. + * @returns The listening endpoint and quiescent shutdown owner. + */ +export async function startInspectorWorker(boot: InspectorWorkerBoot): Promise { + const networkStore = new NetworkStore({ + maxRetainedRequests: boot.config.maxRetainedRequests, + maxJournalBytes: boot.config.maxJournalBytes, + }) + const network = new NetworkDomain(networkStore) + const cordisTrees = new CordisTreeStore({ + maxNodes: boot.config.maxCordisNodes, + maxDisconnectedTrees: boot.config.maxDisconnectedCordisTrees, + }) + const sources = new InspectorSourceRegistry( + [networkStore, cordisTrees], + boot.config.maxSourceFrameBytes, + boot.config.maxSourceRecordsPerFrame, + ) + const clientRuntime = new ClientRuntimeRouter(sources, boot.config.clientRuntimeTimeoutMs) + const clientSources = new ClientSourceRouter( + sources, + boot.config.clientRuntimeTimeoutMs, + boot.config.maxClientSourceBytes, + boot.config.maxSourceFrameBytes, + ) + const realms = new InspectorRealmRegistry(new HostInspectorRealm('Host'), clientRuntime, clientSources) + const cordisDom = new CordisDomBackend(cordisTrees) + const cordisReader = createCordisRuntimeTreeReader(() => cordisTrees.readTree()) + const queries = new InspectorQueryRouter(cordisReader, boot.config.maxSourceFrameBytes) + const unsubscribeQueries = sources.subscribeEvents((event) => { + if (event.type === 'closed') queries.disconnect(event.source) + }) + const hostQueries = queries.open({ + send: (frame) => { boot.hostSourcePort.postMessage(frame) }, + close: () => { boot.hostSourcePort.close() }, + }) + const hostConnection: SourceConnection = { + kind: 'host', + send: (frame: WorkerToSourceFrame) => { + boot.hostSourcePort.postMessage(frame) + if (frame.t === 'source/accepted') hostQueries.accept(frame.sourceId, frame.generation) + }, + close: () => { boot.hostSourcePort.close() }, + } + boot.hostSourcePort.on('message', (value: unknown) => { + if (!hostQueries.receive(value)) sources.receive(hostConnection, value) + }) + boot.hostSourcePort.on('close', () => { + hostQueries.close() + sources.disconnect(hostConnection, 'Host source disconnected') + }) + boot.hostSourcePort.start() + + const endpointOwner = new InspectorEndpoint( + boot.config, + sources, + network, + realms, + cordisDom, + cordisReader, + queries, + ) + const endpoint = await endpointOwner.start() + let closed: Promise | undefined + return { + endpoint, + close(): Promise { + closed ??= (async () => { + await endpointOwner.close() + network.close() + networkStore.dispose() + cordisDom.close() + realms.close() + clientRuntime.close() + clientSources.close() + hostQueries.close() + sources.close() + unsubscribeQueries() + queries.close() + boot.hostSourcePort.close() + })() + return closed + }, + } +} diff --git a/packages/experimental/inspector/tests/built-lib.e2e.ts b/packages/experimental/inspector/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..00229c79da --- /dev/null +++ b/packages/experimental/inspector/tests/built-lib.e2e.ts @@ -0,0 +1,56 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { execa } from 'execa' +import { describe, expect, it } from 'vitest' + +const packageDirectory = fileURLToPath(new URL('..', import.meta.url)) +const built = [ + 'lib/index.js', + 'lib/worker.js', + 'node_modules/@deepseek-ai/schemastery/lib/index.mjs', +].every(file => existsSync(join(packageDirectory, file))) + +describe.skipIf(!built)('experimental Inspector built artifact', () => { + it('starts its sibling Worker and evaluates the Host through plain Node', async () => { + const script = ` + const { startInspector } = await import('@deepseek-ai/dsh-experimental-inspector') + const { default: WebSocket } = await import('ws') + globalThis.__builtInspectorProbe = 42 + const inspector = await startInspector({ port: 0, captureFetch: false }) + const socket = new WebSocket(inspector.endpoint.webSocketDebuggerUrl) + await new Promise((resolve, reject) => { + socket.once('open', resolve) + socket.once('error', reject) + }) + const response = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('CDP response timeout')), 5000) + socket.on('message', data => { + const message = JSON.parse(Buffer.from(data).toString('utf8')) + if (message.id !== 1) return + clearTimeout(timer) + resolve(message) + }) + }) + socket.send(JSON.stringify({ + id: 1, + method: 'Runtime.evaluate', + params: { expression: 'globalThis.__builtInspectorProbe', returnByValue: true }, + })) + const message = await response + socket.close() + await inspector.close() + console.log(JSON.stringify(message.result.result)) + ` + const result = await execa(process.execPath, ['--input-type=module', '-e', script], { + cwd: packageDirectory, + stdin: 'ignore', + timeout: 20_000, + killSignal: 'SIGKILL', + reject: false, + }) + + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0) + expect(JSON.parse(result.stdout.trim()) as unknown).toEqual({ type: 'number', value: 42, description: '42' }) + }) +}) diff --git a/packages/experimental/inspector/tests/client-browser.e2e.ts b/packages/experimental/inspector/tests/client-browser.e2e.ts new file mode 100644 index 0000000000..e9d1b841fe --- /dev/null +++ b/packages/experimental/inspector/tests/client-browser.e2e.ts @@ -0,0 +1,257 @@ +import { existsSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createServer, type Server } from 'node:http' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium, type Browser, type Page } from 'playwright' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it } from 'vitest' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' + +const packageDirectory = fileURLToPath(new URL('..', import.meta.url)) +const clientBundlePath = join(packageDirectory, 'lib/client.js') +const clientSourceMapPath = join(packageDirectory, 'lib/client.js.map') +const built = existsSync(clientBundlePath) && existsSync(clientSourceMapPath) + +interface CdpMessage { + readonly id?: number + readonly method?: string + readonly params?: Record + readonly result?: Record + readonly error?: { message: string } +} + +class BrowserTestCdpClient { + private nextId = 0 + private readonly pending = new Map void>() + private readonly events: CdpMessage[] = [] + private readonly waiters = new Set<() => void>() + + private constructor(private readonly socket: WebSocket) { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpMessage + if (message.id !== undefined) { + this.pending.get(message.id)?.(message) + return + } + this.events.push(message) + for (const waiter of [...this.waiters]) waiter() + }) + } + + static async connect(url: string): Promise { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new BrowserTestCdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + const id = ++this.nextId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`CDP call timed out: ${method}`)) + }, 5_000) + this.pending.set(id, (message) => { + clearTimeout(timer) + this.pending.delete(id) + resolve(message) + }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + waitForEvent(method: string, predicate: (message: CdpMessage) => boolean): Promise { + const existing = this.events.find(event => event.method === method && predicate(event)) + if (existing !== undefined) return Promise.resolve(existing) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.waiters.delete(check) + reject(new Error(`CDP event timed out: ${method}`)) + }, 5_000) + const check = (): void => { + const event = this.events.find(candidate => candidate.method === method && predicate(candidate)) + if (event === undefined) return + clearTimeout(timer) + this.waiters.delete(check) + resolve(event) + } + this.waiters.add(check) + }) + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((resolve) => { this.socket.once('close', () => { resolve() }) }) + this.socket.close() + await closed + } +} + +describe.skipIf(!built)('Inspector built Client in Chromium', () => { + let inspector: InspectorHandle | undefined + let server: Server | undefined + let browser: Browser | undefined + let page: Page | undefined + let cdp: BrowserTestCdpClient | undefined + + afterEach(async () => { + await page?.evaluate(() => { + const state = Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') as { dispose?: () => void } | undefined + state?.dispose?.() + }).catch(() => {}) + await cdp?.close() + await browser?.close() + await inspector?.close() + if (server !== undefined) await new Promise((resolve) => { server!.close(() => { resolve() }) }) + page = undefined + cdp = undefined + browser = undefined + inspector = undefined + server = undefined + }) + + it('forwards Console values and exposes the built bundle as read-only source', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 }) + const bundle = await readFile(clientBundlePath) + const sourceMap = await readFile(clientSourceMapPath) + server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1') + if (url.pathname === '/client.js') { + response.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }) + response.end(bundle) + return + } + if (url.pathname === '/client.js.map') { + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8' }) + response.end(sourceMap) + return + } + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(browserFixture(inspector!.endpoint.client)) + }) + await new Promise((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) }) + const port = (server.address() as import('node:net').AddressInfo).port + + browser = await chromium.launch() + page = await browser.newPage() + await page.goto(`http://127.0.0.1:${String(port)}/`) + await page.waitForFunction(() => Reflect.get(globalThis, '__INSPECTOR_BROWSER_TEST__') !== undefined) + + cdp = await BrowserTestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + const contextEvent = await cdp.waitForEvent('Runtime.executionContextCreated', (event) => { + const context = event.params?.context as Record | undefined + return String(context?.name).startsWith('Client —') + }) + const contextId = (contextEvent.params?.context as Record).id + expect(contextId).toBeTypeOf('number') + + await page.evaluate(() => { + const value = { browser: true, nested: { ready: true } } + Reflect.set(globalThis, '__inspectorBrowserValue', value) + console.log(value, 'browser-client-console') + setTimeout(() => { throw new Error('browser-client-exception') }, 0) + }) + const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', event => + event.params?.executionContextId === contextId && hasArgument(event, 'browser-client-console')) + const args = consoleEvent.params?.args + if (!Array.isArray(args)) throw new Error('Client Console event has no arguments') + expect((consoleEvent.params?.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length) + .toBeGreaterThan(0) + const objectId = asRecord(args[0]).objectId + expect(String(objectId)).toMatch(/^runtime:/u) + const properties = await cdp.call('Runtime.getProperties', { objectId, ownProperties: true }) + expect(propertyValue(properties, 'browser')).toBe(true) + const exception = await cdp.waitForEvent('Runtime.exceptionThrown', (event) => { + const details = event.params?.exceptionDetails as Record | undefined + return details !== undefined + && details.executionContextId === contextId + && String((details.exception as Record | undefined)?.description).includes('browser-client-exception') + }) + const exceptionDetails = exception.params?.exceptionDetails as Record + expect((exceptionDetails.stackTrace as { callFrames?: unknown[] } | undefined)?.callFrames?.length) + .toBeGreaterThan(0) + + const enabled = await cdp.call('Debugger.enable') + expect(enabled.error).toBeUndefined() + expect(enabled.result?.debuggerId).toBeTypeOf('string') + const script = await cdp.waitForEvent('Debugger.scriptParsed', event => + String(event.params?.url).includes('/client.js?rev=browser-test')) + expect(script.params).toMatchObject({ executionContextId: contextId, buildId: '' }) + const scriptId = script.params?.scriptId + const content = await cdp.call('Debugger.getScriptSource', { scriptId }) + expect(String(content.result?.scriptSource)).toContain('ClientInspectorSource') + expect((await cdp.call('Debugger.setBreakpointByUrl', { + url: script.params?.url, + lineNumber: 0, + })).error?.message).toContain('Client native debugging is unavailable') + }, 20_000) +}) + +function browserFixture(bootstrap: InspectorHandle['endpoint']['client']): string { + const boot = { + rev: 'browser-test', + entries: [{ + id: '@deepseek-ai/dsh-experimental-inspector', + url: '/client.js?rev=browser-test', + rev: 'browser-test', + }], + } + return ` +Inspector Browser Client + + +` +} + +function hasArgument(event: CdpMessage, value: unknown): boolean { + const args = event.params?.args + return Array.isArray(args) && args.some(argument => asRecord(argument).value === value) +} + +function propertyValue(response: CdpMessage, name: string): unknown { + const result = response.result?.result + if (!Array.isArray(result)) throw new Error('Runtime.getProperties returned no property list') + const property = result.map(asRecord).find(candidate => candidate.name === name) + return asRecord(property?.value).value +} + +function asRecord(value: unknown): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record') + return value as Readonly> +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} diff --git a/packages/experimental/inspector/tests/client-stack.host.spec.ts b/packages/experimental/inspector/tests/client-stack.host.spec.ts new file mode 100644 index 0000000000..d6080f326f --- /dev/null +++ b/packages/experimental/inspector/tests/client-stack.host.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import { ClientScriptIdentity } from '../src/worker/realms/client/scripts.ts' +import { clientConsoleEvent } from '../src/worker/realms/client/values.ts' + +describe('Worker Client stack projection', () => { + it('uses one script key in Client Console and Sources projections', () => { + const localKey = inspectorId<'RuntimeScriptKey'>('client-bundle', 'scriptKey') + const scripts = new ClientScriptIdentity(-7) + const projected = clientConsoleEvent({ + type: 'console-api', + event: { + type: 'log', + arguments: [], + timestamp: 1, + stackTrace: { + callFrames: [{ + functionName: 'apply', + scriptKey: localKey, + url: 'http://client.test/client.js', + lineNumber: 1, + columnNumber: 2, + }], + }, + }, + }, scriptKey => scripts.toRuntime(scriptKey)) + if (projected.type !== 'console-api') throw new Error('unexpected exception event') + expect(projected.event.stackTrace?.callFrames[0]?.scriptKey).toBe(scripts.toRuntime(localKey)) + }) +}) diff --git a/packages/experimental/inspector/tests/debugger.e2e.ts b/packages/experimental/inspector/tests/debugger.e2e.ts new file mode 100644 index 0000000000..a7cb27e2b6 --- /dev/null +++ b/packages/experimental/inspector/tests/debugger.e2e.ts @@ -0,0 +1,198 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it } from 'vitest' +import { isPlainObject } from '../src/shared/json.ts' + +interface CdpMessage { + readonly id?: number + readonly method?: string + readonly params?: Record + readonly result?: Record + readonly error?: { message: string } +} + +class CdpClient { + private nextId = 0 + private readonly pending = new Map void>() + private readonly events: CdpMessage[] = [] + private readonly eventWaiters = new Set<() => void>() + + private constructor(private readonly socket: WebSocket) { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpMessage + if (message.id !== undefined) this.pending.get(message.id)?.(message) + else { + this.events.push(message) + for (const wake of [...this.eventWaiters]) wake() + } + }) + } + + static async connect(url: string): Promise { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new CdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + const id = ++this.nextId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000) + this.pending.set(id, (message) => { + clearTimeout(timer) + this.pending.delete(id) + resolve(message) + }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + waitForEvent(method: string, predicate: (event: CdpMessage) => boolean = () => true): Promise { + const found = this.events.find(event => event.method === method && predicate(event)) + if (found !== undefined) return Promise.resolve(found) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.eventWaiters.delete(check) + reject(new Error(`CDP event timed out: ${method}`)) + }, 5_000) + const check = (): void => { + const event = this.events.find(candidate => candidate.method === method && predicate(candidate)) + if (event === undefined) return + clearTimeout(timer) + this.eventWaiters.delete(check) + resolve(event) + } + this.eventWaiters.add(check) + }) + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((resolve) => { this.socket.once('close', () => { resolve() }) }) + this.socket.close() + await closed + } +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +describe('Host debugger through the Inspector Worker', () => { + let child: ChildProcessWithoutNullStreams | undefined + let cdp: CdpClient | undefined + + afterEach(async () => { + await cdp?.close() + cdp = undefined + if (child !== undefined && child.exitCode === null) child.kill('SIGKILL') + child = undefined + }) + + it('evaluates a paused Host frame and resumes while the main thread is stopped', async () => { + const fixture = fileURLToPath(new URL('./fixtures/debug-host.ts', import.meta.url)) + const tsx = import.meta.resolve('tsx/esm') + child = spawn(process.execPath, ['--import', tsx, fixture], { + env: { ...process.env, TSX_TSCONFIG_PATH: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + const firstLine = await readLine(child) + const endpoint = JSON.parse(firstLine) as { webSocketDebuggerUrl: string } + cdp = await CdpClient.connect(endpoint.webSocketDebuggerUrl) + expect((await cdp.call('Runtime.enable')).error).toBeUndefined() + expect((await cdp.call('Debugger.enable')).error).toBeUndefined() + const parsed = await cdp.waitForEvent('Debugger.scriptParsed', event => + String(event.params?.url).endsWith('/debug-host.ts')) + const scriptId = parsed.params?.scriptId + expect(typeof scriptId).toBe('string') + const source = await cdp.call('Debugger.getScriptSource', { scriptId }) + expect(source.result?.scriptSource).toContain('breakpointProbe') + await cdp.call('Runtime.evaluate', { expression: 'console.log("host-console-probe")' }) + const consoleEvent = await cdp.waitForEvent('Runtime.consoleAPICalled', (event) => { + const args = event.params?.args + return Array.isArray(args) && args.some(arg => isPlainObject(arg) && arg.value === 'host-console-probe') + }) + expect(consoleEvent.params?.type).toBe('log') + const evaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__inspectorBreakpointProbe', + }) + const objectId = (evaluated.result?.result as Record | undefined)?.objectId + expect(typeof objectId).toBe('string') + expect((await cdp.call('Debugger.setBreakpointOnFunctionCall', { objectId })).error).toBeUndefined() + + child.stdin.write('run\n') + const paused = await cdp.waitForEvent('Debugger.paused') + const callFrames = paused.params?.callFrames as Array> + const callFrameId = callFrames[0]?.callFrameId + expect(typeof callFrameId).toBe('string') + const scopeChain = callFrames[0]?.scopeChain as Array> + const scopeObjectId = (scopeChain[0]?.object as Record | undefined)?.objectId + expect(String(scopeObjectId)).toMatch(/^runtime:/u) + expect((await cdp.call('Runtime.getProperties', { objectId: scopeObjectId })).error).toBeUndefined() + + // This Worker-local request must complete while the Host main thread is paused. + expect((await cdp.call('DSHInspector.getSources')).result?.sources).toBeDefined() + const local = await cdp.call('Debugger.evaluateOnCallFrame', { + callFrameId, + expression: 'value', + returnByValue: true, + }) + expect(local.result?.result).toMatchObject({ type: 'number', value: 41 }) + const object = await cdp.call('Debugger.evaluateOnCallFrame', { + callFrameId, + expression: '({ pausedValue: value })', + objectGroup: 'backtrace', + }) + const pausedObjectId = (object.result?.result as Record | undefined)?.objectId + expect(String(pausedObjectId)).toMatch(/^runtime:/u) + expect((await cdp.call('Runtime.getProperties', { objectId: pausedObjectId })).error).toBeUndefined() + expect((await cdp.call('Debugger.resume')).error).toBeUndefined() + const completed = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__inspectorBreakpointResult', + returnByValue: true, + }) + expect(completed.result?.result).toMatchObject({ type: 'number', value: 42 }) + + const exited = new Promise((resolve) => { child!.once('exit', resolve) }) + child.stdin.write('stop\n') + expect(await exited).toBe(0) + child = undefined + cdp = undefined + }, 20_000) +}) + +function readLine(child: ChildProcessWithoutNullStreams): Promise { + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + const onData = (chunk: Buffer): void => { + stdout += chunk.toString('utf8') + const newline = stdout.indexOf('\n') + if (newline === -1) return + cleanup() + resolve(stdout.slice(0, newline)) + } + const onError = (error: Error): void => { cleanup(); reject(error) } + const onExit = (): void => { + cleanup() + reject(new Error(`debug Host exited before output; stderr:\n${stderr}`)) + } + const onStderr = (chunk: Buffer): void => { stderr += chunk.toString('utf8') } + const cleanup = (): void => { + child.stdout.off('data', onData) + child.stderr.off('data', onStderr) + child.off('error', onError) + child.off('exit', onExit) + } + child.stdout.on('data', onData) + child.stderr.on('data', onStderr) + child.once('error', onError) + child.once('exit', onExit) + }) +} diff --git a/packages/experimental/inspector/tests/fixtures/client-source.client.ts b/packages/experimental/inspector/tests/fixtures/client-source.client.ts new file mode 100644 index 0000000000..a6616facda --- /dev/null +++ b/packages/experimental/inspector/tests/fixtures/client-source.client.ts @@ -0,0 +1,114 @@ +/** Client-face process fixture used by Host-side protocol integration tests. */ + +import { parentPort, workerData } from 'node:worker_threads' +import { Context } from '@deepseek-ai/cordis' +import WebSocket from 'ws' +import { ClientInspectorSource } from '../../src/client/bridge/transport.ts' +import { ClientSourceCatalog } from '../../src/client/cdp/sources.ts' +import { publishCordisTree } from '../../src/client/inspection/cordis.ts' +import { inspectorId } from '../../src/shared/bridge/ids.ts' +import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts' +import type { InspectorJsonValue } from '../../src/shared/json.ts' +import { createInspectorService } from '../../src/shared/service.ts' + +interface ClientFixtureInput { + readonly bootstrap: InspectorClientBootstrap + readonly label: string + readonly sourceCatalog?: { + readonly sourceText: string + readonly sourceMap: string + readonly sourceUrl: string + readonly sourceMapUrl: string + } +} + +interface ClientFixtureRequest { + readonly id: number + readonly op: 'close' | 'disconnect' | 'get-tree' | 'log-cordis' | 'log-value' | 'publish' | 'set-global' + readonly name?: string + readonly value?: InspectorJsonValue + readonly marker?: string + readonly topic?: string +} + +const port = parentPort +if (port === null) throw new Error('Inspector Client fixture requires a Worker parent port') +const input = workerData as ClientFixtureInput +globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket +console.log = () => {} + +const context = new Context() +const childFiber = context.plugin({ name: 'client-child', apply() {} }) +await childFiber.await() +Reflect.set(globalThis, '__cordisClientProbe', context) +Reflect.set(globalThis, '__cordisClientFiberProbe', childFiber) + +const sourceCatalog = input.sourceCatalog === undefined + ? undefined + : new ClientSourceCatalog([{ + scriptKey: inspectorId<'RuntimeScriptKey'>('bundle', 'scriptKey'), + url: input.sourceCatalog.sourceUrl, + hash: 'test', + sourceMapUrl: input.sourceCatalog.sourceMapUrl, + isModule: false, + loadSource: async () => input.sourceCatalog!.sourceText, + loadSourceMap: async () => input.sourceCatalog!.sourceMap, + }]) +const source = new ClientInspectorSource(input.bootstrap, input.label, sourceCatalog) +const disposeCordis = publishCordisTree(context, source, { + maxNodes: input.bootstrap.maxCordisNodes, + maxBytes: input.bootstrap.maxFrameBytes - 4_096, +}) +const service = createInspectorService(source) + +port.on('message', (message: ClientFixtureRequest) => { + void dispatch(message).then( + (value) => { + port.postMessage({ type: 'response', id: message.id, ok: true, value }) + if (message.op === 'close') port.close() + }, + (error: unknown) => { + port.postMessage({ + type: 'response', + id: message.id, + ok: false, + error: error instanceof Error ? error.message : String(error), + }) + }, + ) +}) +port.postMessage({ type: 'ready', fiberUid: childFiber.uid }) + +async function dispatch(message: ClientFixtureRequest): Promise { + switch (message.op) { + case 'publish': + source.publish(requiredString(message.topic, 'topic'), message.value ?? null) + return undefined + case 'set-global': + Reflect.set(globalThis, requiredString(message.name, 'name'), message.value) + return undefined + case 'log-value': + console.log(message.value, requiredString(message.marker, 'marker')) + return undefined + case 'log-cordis': + console.log(context, childFiber, requiredString(message.marker, 'marker')) + return undefined + case 'get-tree': + return await service.cordis.getTree() + case 'disconnect': { + const socket = Reflect.get(source, 'socket') as WebSocket | undefined + socket?.terminate() + return undefined + } + case 'close': + disposeCordis() + source.close() + await context.fiber.dispose() + return undefined + } +} + +function requiredString(value: string | undefined, field: string): string { + if (value === undefined) throw new Error(`Inspector Client fixture ${field} is required`) + return value +} diff --git a/packages/experimental/inspector/tests/fixtures/client-source.host.ts b/packages/experimental/inspector/tests/fixtures/client-source.host.ts new file mode 100644 index 0000000000..f63d71a881 --- /dev/null +++ b/packages/experimental/inspector/tests/fixtures/client-source.host.ts @@ -0,0 +1,141 @@ +/** Host-side controller for the isolated Client test fixture. */ + +import { Worker } from 'node:worker_threads' +import type { InspectorClientBootstrap } from '../../src/shared/bridge/messages/control.ts' +import type { CordisRuntimeTree } from '../../src/shared/cordis/model.ts' +import type { InspectorJsonValue } from '../../src/shared/json.ts' + +/** Optional source artifact exposed by the Client fixture. */ +export interface ClientFixtureSourceCatalog { + readonly sourceText: string + readonly sourceMap: string + readonly sourceUrl: string + readonly sourceMapUrl: string +} + +/** Options for one isolated Client fixture. */ +export interface ClientFixtureOptions { + readonly label?: string + readonly sourceCatalog?: ClientFixtureSourceCatalog +} + +interface FixtureResponse { + readonly type: 'response' + readonly id: number + readonly ok: boolean + readonly value?: unknown + readonly error?: string +} + +/** A Client producer running outside the Host test realm. */ +export class InspectorClientFixture { + private readonly worker: Worker + private readonly pending = new Map>() + private nextId = 0 + private closed = false + readonly fiberUid: number + + private constructor(worker: Worker, fiberUid: number) { + this.worker = worker + this.fiberUid = fiberUid + worker.on('message', (message: unknown) => { this.receive(message) }) + worker.on('error', (error) => { this.fail(error) }) + worker.on('exit', (code) => { + if (!this.closed && code !== 0) this.fail(new Error(`Inspector Client fixture exited with code ${String(code)}`)) + }) + } + + /** Start one Client fixture and wait for its Cordis tree to be published. */ + static async start( + bootstrap: InspectorClientBootstrap, + options: ClientFixtureOptions = {}, + ): Promise { + const ready = Promise.withResolvers() + const entry = new URL('./client-source.client.ts', import.meta.url) + const tsxApi = import.meta.resolve('tsx/esm/api') + const source = `import { register } from ${JSON.stringify(tsxApi)}\nregister()\nawait import(${JSON.stringify(entry.href)})` + const worker = new Worker(new URL(`data:text/javascript,${encodeURIComponent(source)}`), { + execArgv: [], + workerData: { + bootstrap, + label: options.label ?? 'Test Client', + ...(options.sourceCatalog === undefined ? {} : { sourceCatalog: options.sourceCatalog }), + }, + }) + const onMessage = (message: unknown): void => { + if (!isRecord(message) || message.type !== 'ready' || typeof message.fiberUid !== 'number') return + ready.resolve(message.fiberUid) + } + worker.on('message', onMessage) + worker.once('error', ready.reject) + const fiberUid = await ready.promise + worker.off('message', onMessage) + return new InspectorClientFixture(worker, fiberUid) + } + + /** Publish one observation from the Client realm. */ + async publish(topic: string, value: InspectorJsonValue): Promise { + await this.request({ op: 'publish', topic, value }) + } + + /** Set one JSON-compatible global used by Client Runtime evaluation. */ + async setGlobal(name: string, value: InspectorJsonValue): Promise { + await this.request({ op: 'set-global', name, value }) + } + + /** Emit one Console event carrying a caller-provided value. */ + async log(value: InspectorJsonValue, marker: string): Promise { + await this.request({ op: 'log-value', value, marker }) + } + + /** Emit one Console event carrying the fixture's Context and Fiber. */ + async logCordis(marker: string): Promise { + await this.request({ op: 'log-cordis', marker }) + } + + /** Read the consumer-neutral Cordis tree through the Client service. */ + async getCordisTree(): Promise { + return await this.request({ op: 'get-tree' }) as CordisRuntimeTree + } + + /** Break the active ingest socket while preserving the Client source. */ + async disconnect(): Promise { + await this.request({ op: 'disconnect' }) + } + + /** Dispose the Client source and its Cordis context. */ + async close(): Promise { + if (this.closed) return + await this.request({ op: 'close' }) + this.closed = true + await this.worker.terminate() + } + + private async request(fields: Record): Promise { + if (this.closed) throw new Error('Inspector Client fixture is closed') + const id = ++this.nextId + const result = Promise.withResolvers() + this.pending.set(id, result) + this.worker.postMessage({ id, ...fields }) + return await result.promise + } + + private receive(message: unknown): void { + if (!isRecord(message) || message.type !== 'response' || typeof message.id !== 'number') return + const response = message as unknown as FixtureResponse + const pending = this.pending.get(response.id) + if (pending === undefined) return + this.pending.delete(response.id) + if (response.ok) pending.resolve(response.value) + else pending.reject(new Error(response.error ?? 'Inspector Client fixture request failed')) + } + + private fail(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error) + this.pending.clear() + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/experimental/inspector/tests/fixtures/debug-host.ts b/packages/experimental/inspector/tests/fixtures/debug-host.ts new file mode 100644 index 0000000000..880831920c --- /dev/null +++ b/packages/experimental/inspector/tests/fixtures/debug-host.ts @@ -0,0 +1,28 @@ +/** Child-process fixture whose Host main thread is paused and resumed through the Inspector Worker. */ + +import { createInterface } from 'node:readline' +import { startInspector } from '../../src/host/bridge/controller.ts' + +const inspector = await startInspector({ port: 0, captureFetch: false }) + +function breakpointProbe(value: number): number { + const local = value + return local + 1 +} + +Object.defineProperty(globalThis, '__inspectorBreakpointProbe', { value: breakpointProbe, configurable: true }) +process.stdout.write(`${JSON.stringify(inspector.endpoint)}\n`) + +const input = createInterface({ input: process.stdin, terminal: false }) +input.on('line', (line) => { + if (line === 'run') { + Object.defineProperty(globalThis, '__inspectorBreakpointResult', { + value: breakpointProbe(41), + configurable: true, + }) + } + if (line === 'stop') { + input.close() + void inspector.close().then(() => { process.exit(0) }) + } +}) diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts new file mode 100644 index 0000000000..b5c7bd914e --- /dev/null +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -0,0 +1,545 @@ +/** Host-driven integration over an isolated Client fixture. */ + +import { createServer, type Server } from 'node:http' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import { InspectorClientFixture } from './fixtures/client-source.host.ts' + +interface CdpMessage { + readonly id?: number + readonly method?: string + readonly params?: Record + readonly result?: Record + readonly error?: { message: string } +} + +class TestCdpClient { + private nextId = 0 + private readonly pending = new Map void>() + readonly events: CdpMessage[] = [] + + private constructor(private readonly socket: WebSocket) { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpMessage + if (message.id !== undefined) this.pending.get(message.id)?.(message) + else this.events.push(message) + }) + } + + static async connect(url: string): Promise { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new TestCdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + const id = ++this.nextId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id) + reject(new Error(`CDP call timed out: ${method}`)) + }, 5_000) + this.pending.set(id, (message) => { + clearTimeout(timer) + this.pending.delete(id) + resolve(message) + }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((resolve) => { this.socket.once('close', () => { resolve() }) }) + this.socket.close() + await closed + } +} + +describe('experimental Inspector real Worker', () => { + let inspector: InspectorHandle | undefined + let cdp: TestCdpClient | undefined + let secondCdp: TestCdpClient | undefined + let client: InspectorClientFixture | undefined + let server: Server | undefined + + afterEach(async () => { + await client?.close() + client = undefined + await cdp?.close() + cdp = undefined + await secondCdp?.close() + secondCdp = undefined + await inspector?.close() + inspector = undefined + if (server !== undefined) await new Promise((resolve) => { server!.close(() => { resolve() }) }) + server = undefined + }) + + it('switches between Host and Client contexts and routes Client RemoteObjects', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, clientReconnectBaseMs: 10, clientReconnectMaxMs: 20 }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + inspector.source.publish('host/probe', { value: 1 }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Test Client' }) + await client.publish('client/probe', { value: 2 }) + + await vi.waitFor(async () => { + const response = await cdp!.call('DSHInspector.getSources') + const sources = response.result?.sources as Array<{ kind: string; topics: Record }> + expect(sources).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'host', topics: { 'host/probe': 1 } }), + expect.objectContaining({ + kind: 'client', + topics: expect.objectContaining({ 'client/probe': 1 }), + }), + ])) + }) + + ;(globalThis as Record).__inspectorHostProbe = 73 + expect((await cdp.call('Runtime.enable')).error).toBeUndefined() + let clientContextId: number | undefined + let clientUniqueContextId: string | undefined + await vi.waitFor(() => { + expect(runtimeContexts(cdp!).some(context => context.name === 'Host')).toBe(true) + const clientContext = cdp!.events + .filter(event => event.method === 'Runtime.executionContextCreated') + .map(event => event.params?.context as Record | undefined) + .find(context => String(context?.name).startsWith('Client —')) + expect(clientContext).toBeDefined() + clientContextId = clientContext?.id as number + clientUniqueContextId = clientContext?.uniqueId as string + }) + if (clientContextId === undefined || clientUniqueContextId === undefined) { + throw new Error('Client execution context was not announced') + } + const hostEvaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__inspectorHostProbe', + returnByValue: true, + }) + expect(hostEvaluated.result?.result).toMatchObject({ type: 'number', value: 73 }) + + await client.setGlobal('__inspectorClientProbe', { value: 17, nested: { ready: true } }) + const clientEvaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__inspectorClientProbe', + contextId: clientContextId, + objectGroup: 'console', + generatePreview: true, + }) + const clientObject = clientEvaluated.result?.result as Record + expect(clientObject).toMatchObject({ type: 'object', className: 'Object' }) + expect(String(clientObject.objectId)).toMatch(/^runtime:/u) + + const properties = await cdp.call('Runtime.getProperties', { + objectId: clientObject.objectId, + ownProperties: true, + }) + const propertyRows = recordArray(properties.result?.result) + const valueProperty = propertyRows.find(property => property.name === 'value') + const nestedProperty = propertyRows.find(property => property.name === 'nested') + expect(asRecord(valueProperty?.value)).toMatchObject({ type: 'number', value: 17 }) + expect(asRecord(nestedProperty?.value).type).toBe('object') + + const called = await cdp.call('Runtime.callFunctionOn', { + objectId: clientObject.objectId, + functionDeclaration: 'function (increment) { return this.value + increment }', + arguments: [{ value: 5 }], + returnByValue: true, + }) + expect(called.result?.result).toMatchObject({ type: 'number', value: 22 }) + + const hostObject = await cdp.call('Runtime.evaluate', { expression: '({ realm: "host" })' }) + const hostObjectId = asRecord(hostObject.result?.result).objectId + expect((await cdp.call('Runtime.callFunctionOn', { + executionContextId: clientContextId, + functionDeclaration: 'function (value) { return value }', + arguments: [{ objectId: hostObjectId }], + })).error?.message).toContain('between realms') + expect((await cdp.call('Runtime.callFunctionOn', { + objectId: hostObjectId, + functionDeclaration: 'function (value) { return value }', + arguments: [{ objectId: clientObject.objectId }], + })).error?.message).toContain('between realms') + expect((await cdp.call('Runtime.queryObjects', { + prototypeObjectId: clientObject.objectId, + })).error?.message).toContain('Client realm has no native CDP transport') + + const awaited = await cdp.call('Runtime.evaluate', { + expression: 'Promise.resolve({ realm: "client" })', + contextId: clientContextId, + awaitPromise: true, + returnByValue: true, + }) + expect(awaited.result?.result).toMatchObject({ type: 'object', value: { realm: 'client' } }) + + const uniquelyRouted = await cdp.call('Runtime.evaluate', { + expression: '6 * 7', + uniqueContextId: clientUniqueContextId, + returnByValue: true, + }) + expect(uniquelyRouted.result?.result).toMatchObject({ type: 'number', value: 42 }) + + expect((await cdp.call('Runtime.releaseObject', { objectId: clientObject.objectId })).error).toBeUndefined() + expect((await cdp.call('Runtime.getProperties', { objectId: clientObject.objectId })).error).toBeDefined() + + const thrown = await cdp.call('Runtime.evaluate', { + expression: 'throw new Error("client failure")', + contextId: clientContextId, + }) + expect(asRecord(thrown.result?.exceptionDetails)).toMatchObject({ + text: 'Uncaught', + executionContextId: clientContextId, + }) + + const pendingEvaluation = cdp.call('Runtime.evaluate', { + expression: 'new Promise(() => {})', + contextId: clientContextId, + awaitPromise: true, + }) + await new Promise((resolve) => { setTimeout(resolve, 10) }) + await client.close() + client = undefined + expect((await pendingEvaluation).error).toBeDefined() + await vi.waitFor(() => { + expect(cdp!.events.some(event => + event.method === 'Runtime.executionContextDestroyed' + && event.params?.executionContextId === clientContextId)).toBe(true) + }) + }) + + it('isolates Client object ids and object groups by DevTools connection', async () => { + inspector = await startInspector({ port: 0, captureFetch: false }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Shared Client' }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')]) + + const firstContext = await clientContext(cdp) + const secondContext = await clientContext(secondCdp) + const first = await cdp.call('Runtime.evaluate', { + expression: '({ owner: "first" })', + contextId: firstContext, + objectGroup: 'console', + }) + const second = await secondCdp.call('Runtime.evaluate', { + expression: '({ owner: "second" })', + contextId: secondContext, + objectGroup: 'console', + }) + const firstObjectId = asRecord(first.result?.result).objectId + const secondObjectId = asRecord(second.result?.result).objectId + expect(firstObjectId).not.toBe(secondObjectId) + expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined() + + await cdp.close() + cdp = undefined + const secondProperties = await secondCdp.call('Runtime.getProperties', { + objectId: secondObjectId, + ownProperties: true, + }) + const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner') + expect(asRecord(owner?.value).value).toBe('second') + expect((await secondCdp.call('Runtime.releaseObjectGroup', { objectGroup: 'console' })).error).toBeUndefined() + expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeDefined() + + const beforeDisable = await secondCdp.call('Runtime.evaluate', { + expression: '({ retained: true })', + contextId: secondContext, + }) + const disabledObjectId = asRecord(beforeDisable.result?.result).objectId + expect((await secondCdp.call('Runtime.disable')).error).toBeUndefined() + expect((await secondCdp.call('Runtime.enable')).error).toBeUndefined() + expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined() + }) + + it('uses the same Runtime value model for Host and Client realms', async () => { + inspector = await startInspector({ port: 0, captureFetch: false }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + const clientContextId = await clientContext(cdp) + + for (const [name, contextId] of [['Host', undefined], ['Client', clientContextId]] as const) { + const select = contextId === undefined ? {} : { contextId } + const nan = await cdp.call('Runtime.evaluate', { expression: 'NaN', ...select }) + expect(nan.result?.result, name).toMatchObject({ type: 'number', unserializableValue: 'NaN' }) + + const array = await cdp.call('Runtime.evaluate', { + expression: '[1, 2]', + objectGroup: `compat-${name}`, + ...select, + }) + const arrayObject = asRecord(array.result?.result) + expect(arrayObject, name).toMatchObject({ type: 'object', subtype: 'array', className: 'Array' }) + const properties = await cdp.call('Runtime.getProperties', { + objectId: arrayObject.objectId, + ownProperties: true, + }) + const first = recordArray(properties.result?.result).find(property => property.name === '0') + expect(first, name).toMatchObject({ configurable: true, enumerable: true, writable: true }) + expect(asRecord(first?.value), name).toMatchObject({ type: 'number', value: 1 }) + + const thrown = await cdp.call('Runtime.evaluate', { + expression: 'throw new TypeError("realm-compatibility")', + ...select, + }) + expect(thrown.result?.result, name).toMatchObject({ type: 'object', subtype: 'error' }) + expect(thrown.result?.exceptionDetails, name).toMatchObject({ text: 'Uncaught' }) + + expect((await cdp.call('Runtime.releaseObjectGroup', { objectGroup: `compat-${name}` })).error).toBeUndefined() + expect((await cdp.call('Runtime.getProperties', { objectId: arrayObject.objectId })).error).toBeDefined() + } + + expect((await cdp.call('Runtime.evaluate', { + expression: '1 + 1', + throwOnSideEffect: true, + })).result?.result).toMatchObject({ type: 'number', value: 2 }) + expect((await cdp.call('Runtime.evaluate', { + expression: '1 + 1', + contextId: clientContextId, + throwOnSideEffect: true, + })).error?.message).toContain('does not support throwOnSideEffect') + expect((await cdp.call('Runtime.compileScript', { + expression: '1 + 1', + sourceURL: 'client-eval.js', + persistScript: true, + executionContextId: clientContextId, + })).error?.message).toContain('Client realm has no native CDP transport') + }) + + it('forwards Client Console objects through isolated realm sessions', async () => { + inspector = await startInspector({ port: 0, captureFetch: false }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Console Client' }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + secondCdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await Promise.all([cdp.call('Runtime.enable'), secondCdp.call('Runtime.enable')]) + const firstContext = await clientContext(cdp) + const secondContext = await clientContext(secondCdp) + const value = { owner: 'client-console' } + const marker = 'client-console-event' + await client.log(value, marker) + let firstEvent: CdpMessage | undefined + let secondEvent: CdpMessage | undefined + await vi.waitFor(() => { + firstEvent = consoleEvent(cdp!, firstContext, marker) + secondEvent = consoleEvent(secondCdp!, secondContext, marker) + expect(firstEvent).toBeDefined() + expect(secondEvent).toBeDefined() + }) + const firstObjectId = asRecord(recordArray(firstEvent!.params?.args)[0]).objectId + const secondObjectId = asRecord(recordArray(secondEvent!.params?.args)[0]).objectId + expect(firstObjectId).toBeTypeOf('string') + expect(secondObjectId).toBeTypeOf('string') + expect(firstObjectId).not.toBe(secondObjectId) + expect((await secondCdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined() + + const secondProperties = await secondCdp.call('Runtime.getProperties', { + objectId: secondObjectId, + ownProperties: true, + }) + const owner = recordArray(secondProperties.result?.result).find(property => property.name === 'owner') + expect(asRecord(owner?.value).value).toBe('client-console') + + expect((await cdp.call('Runtime.discardConsoleEntries')).error).toBeUndefined() + expect((await cdp.call('Runtime.getProperties', { objectId: firstObjectId })).error).toBeDefined() + expect((await secondCdp.call('Runtime.getProperties', { objectId: secondObjectId })).error).toBeUndefined() + }) + + it('projects a chunked Client bundle as read-only Debugger source', async () => { + const sourceText = `const clientSourceMarker = 42\n/*${'x'.repeat(150_000)}*/\n` + const sourceMap = JSON.stringify({ version: 3, sources: ['client/index.ts'], mappings: 'AAAA' }) + const sourceUrl = 'http://client.test/plugins/inspector/client.js?rev=test' + const sourceMapUrl = 'http://client.test/plugins/inspector/client.js.map?rev=test' + inspector = await startInspector({ port: 0, captureFetch: false, maxClientSourceBytes: 1_000_000 }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { + label: 'Source Client', + sourceCatalog: { sourceText, sourceMap, sourceUrl, sourceMapUrl }, + }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + const contextId = await clientContext(cdp) + expect((await cdp.call('Debugger.enable')).error).toBeUndefined() + + let script: CdpMessage | undefined + await vi.waitFor(() => { + script = cdp!.events.find(event => event.method === 'Debugger.scriptParsed' + && event.params?.url === sourceUrl) + expect(script).toBeDefined() + }) + expect(script?.params).toMatchObject({ + executionContextId: contextId, + sourceMapURL: sourceMapUrl, + hash: 'test', + isModule: false, + length: sourceText.length, + }) + const scriptId = script?.params?.scriptId + expect(scriptId).toBeTypeOf('string') + await expect(cdp.call('Debugger.getScriptSource', { scriptId })).resolves.toMatchObject({ + result: { scriptSource: sourceText }, + }) + await expect(cdp.call('Debugger.searchInContent', { + scriptId, + query: 'clientSourceMarker', + caseSensitive: true, + })).resolves.toMatchObject({ + result: { result: [{ lineNumber: 0, lineContent: 'const clientSourceMarker = 42' }] }, + }) + expect((await cdp.call('Debugger.setBreakpointByUrl', { url: sourceUrl, lineNumber: 0 })).error?.message) + .toContain('Client native debugging is unavailable') + expect((await cdp.call('Debugger.setBreakpointByUrl', { + urlRegex: 'client\\.js', + lineNumber: 0, + })).error?.message).toContain('Client native debugging is unavailable') + expect((await cdp.call('Debugger.setBreakpointByUrl', { + scriptHash: 'test', + lineNumber: 0, + })).error?.message).toContain('Client native debugging is unavailable') + expect((await cdp.call('Debugger.evaluateOnCallFrame', { + callFrameId: 'client:unsupported-frame', + expression: '1', + })).error?.message).toContain('Client native debugging is unavailable') + }) + + it('projects full Host fetch data through the Network domain', async () => { + server = createServer((request, response) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + response.writeHead(201, { authorization: 'response-secret', 'content-type': 'application/json' }) + response.end(JSON.stringify({ body })) + }) + }) + await new Promise((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) }) + const port = (server.address() as import('node:net').AddressInfo).port + inspector = await startInspector({ port: 0 }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Network.enable') + + const response = await fetch(`http://127.0.0.1:${String(port)}/capture?secret=query`, { + method: 'POST', + headers: { authorization: 'Bearer request-secret' }, + body: 'request-body', + }) + expect(await response.json()).toEqual({ body: 'request-body' }) + + let started: CdpMessage | undefined + await vi.waitFor(() => { + started = cdp!.events.find(event => + event.method === 'Network.requestWillBeSent' + && String((event.params?.request as Record | undefined)?.url).includes('/capture')) + expect(started).toBeDefined() + expect(cdp!.events.some(event => + event.method === 'Network.loadingFinished' + && event.params?.requestId === started!.params?.requestId)).toBe(true) + }) + const request = started!.params?.request as Record + expect(request.url).toBe(`http://127.0.0.1:${String(port)}/capture?secret=query`) + expect(request.headers).toMatchObject({ authorization: 'Bearer request-secret' }) + const requestId = started!.params?.requestId + const post = await cdp.call('Network.getRequestPostData', { requestId }) + expect(post.result?.postData).toBe('request-body') + const body = await cdp.call('Network.getResponseBody', { requestId }) + expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe('{"body":"request-body"}') + }) + + it('streams later Host fetch response chunks to an opted-in CDP connection', async () => { + const continueResponse = Promise.withResolvers() + const firstChunk = 'data: first\n\n' + const laterChunk = 'event: update\nid: 2\ndata: second\ndata: line\n\n' + server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' }) + response.write(firstChunk) + void continueResponse.promise.then(() => { response.end(laterChunk) }) + }) + await new Promise((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) }) + const port = (server.address() as import('node:net').AddressInfo).port + inspector = await startInspector({ port: 0 }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Network.enable') + + try { + const response = await fetch(`http://127.0.0.1:${String(port)}/events`) + let requestId: string | undefined + await vi.waitFor(() => { + const received = cdp!.events.find(event => + event.method === 'Network.responseReceived' + && (event.params?.response as Record | undefined)?.mimeType === 'text/event-stream') + requestId = received?.params?.requestId as string | undefined + expect(requestId).toBeTypeOf('string') + expect(cdp!.events.some(event => + event.method === 'Network.dataReceived' + && event.params?.requestId === requestId)).toBe(true) + }) + if (requestId === undefined) throw new Error('SSE request was not observed') + + const streaming = await cdp.call('Network.streamResourceContent', { requestId }) + expect(Buffer.from(String(streaming.result?.bufferedData), 'base64').toString('utf8')).toBe(firstChunk) + const laterEventOffset = cdp.events.length + continueResponse.resolve(true) + expect(await response.text()).toBe(firstChunk + laterChunk) + + await vi.waitFor(() => { + expect(cdp!.events.some(event => + event.method === 'Network.loadingFinished' + && event.params?.requestId === requestId)).toBe(true) + const streamed = cdp!.events.slice(laterEventOffset) + .filter(event => event.method === 'Network.dataReceived' + && event.params?.requestId === requestId + && typeof event.params?.data === 'string') + .map(event => Buffer.from(String(event.params!.data), 'base64')) + expect(Buffer.concat(streamed).toString('utf8')).toBe(laterChunk) + }) + + const body = await cdp.call('Network.getResponseBody', { requestId }) + expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(firstChunk + laterChunk) + } finally { + continueResponse.resolve(true) + } + }) +}) + +async function clientContext(client: TestCdpClient): Promise { + let contextId: number | undefined + await vi.waitFor(() => { + const context = runtimeContexts(client).find(candidate => String(candidate.name).startsWith('Client —')) + expect(context).toBeDefined() + contextId = context?.id as number + }) + if (contextId === undefined) throw new Error('Client execution context was not announced') + return contextId +} + +function runtimeContexts(client: TestCdpClient): Readonly>[] { + return client.events + .filter(event => event.method === 'Runtime.executionContextCreated') + .map(event => asRecord(event.params?.context)) +} + +function consoleEvent(client: TestCdpClient, contextId: number, marker: string): CdpMessage | undefined { + return client.events.find((event) => { + if (event.method !== 'Runtime.consoleAPICalled' || event.params?.executionContextId !== contextId) return false + const args = event.params.args + return Array.isArray(args) && args.some(argument => asRecord(argument).value === marker) + }) +} + +function recordArray(value: unknown): Readonly>[] { + if (!Array.isArray(value)) throw new Error('expected an array of records') + return value.map(asRecord) +} + +function asRecord(value: unknown): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a record') + return value as Readonly> +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} From 7ecd7004ebc1c45c137d9db7d340738236009a01 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:26:32 +0800 Subject: [PATCH 066/130] feat(inspector): project Host fetches through CDP Network --- .../src/client/inspection/network.ts | 4 + .../inspector/src/host/inspection/network.ts | 244 ++++++++++ .../src/shared/bridge/messages/network.ts | 12 + .../src/shared/network/observation.ts | 53 +++ .../src/worker/cdp/domains/network/session.ts | 211 +++++++++ .../src/worker/inspection/network-store.ts | 429 ++++++++++++++++++ .../tests/fetch-observer.host.spec.ts | 124 +++++ .../inspector/tests/network.host.spec.ts | 153 +++++++ 8 files changed, 1230 insertions(+) create mode 100644 packages/experimental/inspector/src/client/inspection/network.ts create mode 100644 packages/experimental/inspector/src/host/inspection/network.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/network.ts create mode 100644 packages/experimental/inspector/src/shared/network/observation.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/network/session.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/network-store.ts create mode 100644 packages/experimental/inspector/tests/fetch-observer.host.spec.ts create mode 100644 packages/experimental/inspector/tests/network.host.spec.ts diff --git a/packages/experimental/inspector/src/client/inspection/network.ts b/packages/experimental/inspector/src/client/inspection/network.ts new file mode 100644 index 0000000000..23f4e6d818 --- /dev/null +++ b/packages/experimental/inspector/src/client/inspection/network.ts @@ -0,0 +1,4 @@ +/** Client network observation is not enabled in the current source producer. */ + +/** Observation topics published by the Client network adapter. */ +export const NETWORK_TOPICS: readonly string[] = [] diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts new file mode 100644 index 0000000000..d73d979fa9 --- /dev/null +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -0,0 +1,244 @@ +/** Full `globalThis.fetch` capture that publishes without delaying response delivery. */ + +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorPublisher } from '../../shared/bridge/publisher.ts' +import { FETCH_TOPICS } from '../../shared/bridge/messages/network.ts' + +/** Observation topics published by the Host network adapter. */ +export const NETWORK_TOPICS: readonly string[] = FETCH_TOPICS + +/** Byte limits for request and response clone capture. */ +export interface FetchCaptureOptions { + readonly maxRequestBodyBytes: number + readonly maxResponseBodyBytes: number + readonly maxChunkBytes: number +} + +interface CaptureOutcome { + readonly capturedBytes: number + readonly truncated: boolean + readonly captureError?: string +} + +/** Active global fetch wrapper. */ +export interface FetchObserver { + /** Restore the prior fetch implementation, cancel clone readers, and await their settlement. */ + stop(): Promise +} + +/** + * Install full fetch capture for every later call through `globalThis.fetch`. + * @param publisher - Host source that receives fetch lifecycle records. + * @param options - Per-body capture limits. + * @returns The owner that stops capture and awaits pending body readers. + */ +export function installFetchObserver( + publisher: InspectorPublisher, + options: FetchCaptureOptions, +): FetchObserver { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch') + const original = globalThis.fetch + if (typeof original !== 'function') throw new Error('inspector: globalThis.fetch is unavailable') + if (descriptor !== undefined && !('value' in descriptor)) { + throw new Error('inspector: globalThis.fetch is an accessor and cannot be observed safely') + } + + const controller = new AbortController() + const pending = new Set>() + let nextRequestId = 0 + + const track = (promise: Promise): void => { + pending.add(promise) + void promise.then( + () => { pending.delete(promise) }, + () => { pending.delete(promise) }, + ) + } + + const observedFetch: typeof fetch = async (input, init) => { + const request = new Request(input, init) + const requestId = `fetch-${++nextRequestId}` + publisher.publish('fetch/start', { + requestId, + url: request.url, + method: request.method, + headers: headerEntries(request.headers), + hasBody: request.body !== null, + wallTimeMs: Date.now(), + }) + + let requestClone: Request | undefined + try { + requestClone = request.clone() + } catch (error) { + publisher.publish('fetch/request-body-end', { + requestId, + capturedBytes: 0, + truncated: false, + captureError: renderError(error), + }) + } + if (requestClone !== undefined) { + track(captureBody( + requestClone.body, + options.maxRequestBodyBytes, + options.maxChunkBytes, + controller.signal, + (data) => { publisher.publish('fetch/request-body-chunk', { requestId, data }) }, + ).then((outcome) => { + publisher.publish('fetch/request-body-end', compactOutcome(requestId, outcome)) + })) + } + + let response: Response + try { + response = await Reflect.apply(original, globalThis, [request]) + } catch (error) { + publisher.publish('fetch/error', { + requestId, + message: renderError(error), + canceled: request.signal.aborted || isAbortError(error), + }) + throw error + } + + publisher.publish('fetch/response', { + requestId, + url: response.url || request.url, + status: response.status, + statusText: response.statusText, + headers: headerEntries(response.headers), + mimeType: response.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '', + }) + + try { + const responseClone = response.clone() + track(captureBody( + responseClone.body, + options.maxResponseBodyBytes, + options.maxChunkBytes, + AbortSignal.any([controller.signal, request.signal]), + (data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) }, + ).then((outcome) => { + if (request.signal.aborted) { + publisher.publish('fetch/error', { + requestId, + message: request.signal.reason === undefined + ? 'AbortError: request aborted during response body capture' + : renderError(request.signal.reason), + canceled: true, + }) + return + } + publisher.publish('fetch/end', { + requestId, + capturedBytes: outcome.capturedBytes, + responseBodyTruncated: outcome.truncated, + ...(outcome.captureError === undefined ? {} : { responseCaptureError: outcome.captureError }), + }) + })) + } catch (error) { + publisher.publish('fetch/end', { + requestId, + capturedBytes: 0, + responseBodyTruncated: false, + responseCaptureError: renderError(error), + }) + } + return response + } + + Object.defineProperty(observedFetch, 'name', { value: original.name, configurable: true }) + Object.defineProperty(observedFetch, 'length', { value: original.length, configurable: true }) + Object.defineProperty(globalThis, 'fetch', descriptor === undefined + ? { value: observedFetch, writable: true, configurable: true } + : { ...descriptor, value: observedFetch }) + + let stopped: Promise | undefined + return { + stop(): Promise { + if (stopped !== undefined) return stopped + stopped = (async () => { + const current = Object.getOwnPropertyDescriptor(globalThis, 'fetch') + if (current !== undefined && 'value' in current && current.value === observedFetch) { + if (descriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch') + else Object.defineProperty(globalThis, 'fetch', descriptor) + } + controller.abort() + await Promise.allSettled([...pending]) + })() + return stopped + }, + } +} + +async function captureBody( + body: ReadableStream | null, + limit: number, + chunkLimit: number, + signal: AbortSignal, + emit: (base64: string) => void, +): Promise { + if (body === null) return { capturedBytes: 0, truncated: false } + const reader = body.getReader() + const abort = (): void => { void reader.cancel(signal.reason).catch(() => undefined) } + signal.addEventListener('abort', abort, { once: true }) + let capturedBytes = 0 + let truncated = false + try { + while (!signal.aborted) { + const item = await reader.read() + if (item.done) break + let offset = 0 + while (offset < item.value.byteLength) { + const remaining = limit - capturedBytes + if (remaining <= 0) { + truncated = true + void reader.cancel('inspector body capture limit reached').catch(() => undefined) + return { capturedBytes, truncated } + } + const size = Math.min(chunkLimit, remaining, item.value.byteLength - offset) + const chunk = item.value.subarray(offset, offset + size) + emit(Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString('base64')) + capturedBytes += size + offset += size + } + } + if (signal.aborted) { + void reader.cancel(signal.reason).catch(() => undefined) + return { capturedBytes, truncated, captureError: 'inspector stopped during body capture' } + } + return { capturedBytes, truncated } + } catch (error) { + return { capturedBytes, truncated, captureError: renderError(error) } + } finally { + signal.removeEventListener('abort', abort) + reader.releaseLock() + } +} + +function compactOutcome(requestId: string, outcome: CaptureOutcome): InspectorJsonValue { + return { + requestId, + capturedBytes: outcome.capturedBytes, + truncated: outcome.truncated, + ...(outcome.captureError === undefined ? {} : { captureError: outcome.captureError }), + } +} + +function headerEntries(headers: Headers): [string, string][] { + return [...headers.entries()] +} + +function isAbortError(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + +function renderError(error: unknown): string { + if (error instanceof Error) return `${error.name}: ${error.message}` + try { + return String(error) + } catch { + return 'unrenderable fetch error' + } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/network.ts b/packages/experimental/inspector/src/shared/bridge/messages/network.ts new file mode 100644 index 0000000000..df83a777aa --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/network.ts @@ -0,0 +1,12 @@ +/** Observation topic names carried by the internal bridge for captured fetches. */ + +/** Complete set of fetch observation topics. */ +export const FETCH_TOPICS = [ + 'fetch/start', + 'fetch/request-body-chunk', + 'fetch/request-body-end', + 'fetch/response', + 'fetch/response-body-chunk', + 'fetch/end', + 'fetch/error', +] as const diff --git a/packages/experimental/inspector/src/shared/network/observation.ts b/packages/experimental/inspector/src/shared/network/observation.ts new file mode 100644 index 0000000000..b3266dc7c7 --- /dev/null +++ b/packages/experimental/inspector/src/shared/network/observation.ts @@ -0,0 +1,53 @@ +/** Full-capture fetch observations sent to the Inspector Worker. */ + +/** One header entry; arrays retain duplicate header names. */ +export type InspectorHeader = readonly [name: string, value: string] + +/** Common request identity. */ +export interface FetchIdentity { + readonly requestId: string +} + +/** A high-level global fetch call began. */ +export interface FetchStartPayload extends FetchIdentity { + readonly url: string + readonly method: string + readonly headers: InspectorHeader[] + readonly hasBody: boolean + readonly wallTimeMs: number +} + +/** One captured request-body chunk. */ +export interface FetchBodyChunkPayload extends FetchIdentity { + readonly data: string +} + +/** Terminal state of one captured request body. */ +export interface FetchRequestBodyEndPayload extends FetchIdentity { + readonly capturedBytes: number + readonly truncated: boolean + readonly captureError?: string +} + +/** Fetch resolved with response headers. */ +export interface FetchResponsePayload extends FetchIdentity { + readonly url: string + readonly status: number + readonly statusText: string + readonly headers: InspectorHeader[] + readonly mimeType: string +} + +/** One captured response-body chunk. */ +/** Fetch capture reached a terminal response-body state. */ +export interface FetchEndPayload extends FetchIdentity { + readonly capturedBytes: number + readonly responseBodyTruncated: boolean + readonly responseCaptureError?: string +} + +/** Fetch rejected before returning a Response. */ +export interface FetchErrorPayload extends FetchIdentity { + readonly message: string + readonly canceled: boolean +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts new file mode 100644 index 0000000000..1988eca41a --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts @@ -0,0 +1,211 @@ +/** CDP Network projection over the Worker-owned normalized network store. */ + +import { Buffer } from 'node:buffer' +import type { InspectorHeader } from '../../../../shared/network/observation.ts' +import type { NetworkStore, NetworkStoreEvent } from '../../../inspection/network-store.ts' + +/** CDP session slice used by the Network domain. */ +export interface NetworkSink { + sendEvent(method: string, params: Readonly>): void +} + +/** Projects retained and live network observations into connection-local CDP state. */ +export class NetworkDomain { + private readonly enabled = new Set() + private readonly streamedRequests = new Map>() + private readonly unsubscribe: () => void + + constructor(private readonly store: NetworkStore) { + this.unsubscribe = store.subscribe((event) => { this.receive(event) }) + } + + /** + * Enable Network for one DevTools connection and replay retained lifecycle events. + * @param session - Connection receiving replay and subsequent events. + */ + enable(session: NetworkSink): void { + if (this.enabled.has(session)) return + for (const event of this.store.replay()) this.send(session, event) + this.enabled.add(session) + } + + /** + * Stop Network events for one DevTools connection. + * @param session - Connection leaving the enabled set. + */ + disable(session: NetworkSink): void { + this.enabled.delete(session) + this.streamedRequests.delete(session) + } + + /** + * Forget a closed DevTools connection. + * @param session - Closed DevTools connection. + */ + detach(session: NetworkSink): void { + this.disable(session) + } + + /** Release the repository subscription and all connection-local state. */ + close(): void { + this.unsubscribe() + this.enabled.clear() + this.streamedRequests.clear() + } + + /** + * Handle one Worker-local Network method. + * @param method - CDP method name. + * @param params - Parsed request parameters. + * @param session - Calling DevTools connection. + * @returns The CDP result fields. + */ + handle(method: string, params: Readonly>, session: NetworkSink): unknown { + switch (method) { + case 'Network.enable': + this.enable(session) + return {} + case 'Network.disable': + this.disable(session) + return {} + case 'Network.getResponseBody': { + const body = this.store.responseBody(params.requestId) + return { + body: Buffer.from(body.bytes).toString('base64'), + base64Encoded: true, + dshInspectorTruncated: body.truncated, + ...(body.captureError === undefined ? {} : { dshInspectorCaptureError: body.captureError }), + } + } + case 'Network.getRequestPostData': { + const body = this.store.requestBody(params.requestId) + return { + postData: Buffer.from(body.bytes).toString('utf8'), + dshInspectorTruncated: body.truncated, + ...(body.captureError === undefined ? {} : { dshInspectorCaptureError: body.captureError }), + } + } + case 'Network.streamResourceContent': { + const body = this.store.responseBody(params.requestId) + if (typeof params.requestId !== 'string') throw new Error('Network requestId must be a string') + if (!body.complete) { + let requests = this.streamedRequests.get(session) + if (requests === undefined) this.streamedRequests.set(session, requests = new Set()) + requests.add(params.requestId) + } + return { bufferedData: Buffer.from(body.bytes).toString('base64') } + } + case 'Network.setCacheDisabled': + case 'Network.setBypassServiceWorker': + case 'Network.setExtraHTTPHeaders': + case 'Network.clearBrowserCache': + case 'Network.clearBrowserCookies': + return {} + default: + throw new Error(`unsupported Network method ${method}`) + } + } + + private receive(event: NetworkStoreEvent): void { + if (event.type === 'request-evicted') { + for (const [session, requests] of this.streamedRequests) { + requests.delete(event.requestKey) + if (requests.size === 0) this.streamedRequests.delete(session) + } + return + } + for (const session of this.enabled) this.send(session, event) + } + + private send(session: NetworkSink, event: Exclude): void { + const timestamp = (event.timestampMs - performance.timeOrigin) / 1_000 + switch (event.type) { + case 'request-started': + session.sendEvent('Network.requestWillBeSent', { + requestId: event.requestId, + loaderId: 'dsh-inspector-loader', + documentURL: 'dsh://host', + request: { + url: event.url, + method: event.method, + headers: cdpHeaders(event.headers), + hasPostData: event.hasBody, + }, + timestamp, + wallTime: event.wallTimeMs / 1_000, + initiator: { type: 'other' }, + type: 'Fetch', + }) + return + case 'response-received': + session.sendEvent('Network.responseReceived', { + requestId: event.requestId, + loaderId: 'dsh-inspector-loader', + frameId: 'dsh-inspector-host-frame', + timestamp, + type: 'Fetch', + response: { + url: event.url, + status: event.status, + statusText: event.statusText, + headers: cdpHeaders(event.headers), + mimeType: event.mimeType, + connectionReused: false, + connectionId: 0, + encodedDataLength: 0, + securityState: 'neutral', + }, + }) + return + case 'response-data': + session.sendEvent('Network.dataReceived', { + requestId: event.requestId, + timestamp, + dataLength: event.byteLength, + encodedDataLength: event.byteLength, + ...(this.streamedRequests.get(session)?.has(event.requestKey) === true ? { data: event.data } : {}), + }) + return + case 'request-finished': + session.sendEvent('Network.loadingFinished', { + requestId: event.requestId, + timestamp, + encodedDataLength: event.encodedDataLength, + dshInspectorTruncated: event.truncated, + }) + this.stopStreaming(event.requestKey) + return + case 'request-failed': + session.sendEvent('Network.loadingFailed', { + requestId: event.requestId, + timestamp, + type: 'Fetch', + errorText: event.errorText, + canceled: event.canceled, + }) + this.stopStreaming(event.requestKey) + return + default: + return assertNever(event) + } + } + + private stopStreaming(requestKey: string): void { + for (const [session, requests] of this.streamedRequests) { + requests.delete(requestKey) + if (requests.size === 0) this.streamedRequests.delete(session) + } + } +} + +function cdpHeaders(entries: readonly InspectorHeader[]): Record { + const headers: Record = Object.create(null) as Record + for (const [name, value] of entries) { + headers[name] = headers[name] === undefined ? value : `${headers[name]}\n${value}` + } + return headers +} + +function assertNever(value: never): never { + throw new Error(`Unexpected network event: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/worker/inspection/network-store.ts b/packages/experimental/inspector/src/worker/inspection/network-store.ts new file mode 100644 index 0000000000..1599667503 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/network-store.ts @@ -0,0 +1,429 @@ +/** Worker-owned repository of normalized fetch observations and captured bodies. */ + +import { Buffer } from 'node:buffer' +import { FETCH_TOPICS } from '../../shared/bridge/messages/network.ts' +import type { InspectorHeader } from '../../shared/network/observation.ts' +import { isPlainObject } from '../../shared/json.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { IngestedInspectorRecord, InspectorRecordConsumer } from '../bridge/hub.ts' + +/** Bounded retention policy for observed network requests. */ +export interface NetworkStoreOptions { + readonly maxRetainedRequests: number + readonly maxJournalBytes: number +} + +/** Captured body data returned without a CDP representation. */ +export interface CapturedNetworkBody { + readonly bytes: Uint8Array + readonly truncated: boolean + readonly captureError?: string + readonly complete: boolean +} + +interface NetworkEventBase { + readonly requestKey: string + readonly requestId: string + readonly timestampMs: number +} + +/** Transport-independent changes emitted by the network repository. */ +export type NetworkStoreEvent = + | NetworkEventBase & { + readonly type: 'request-started' + readonly wallTimeMs: number + readonly url: string + readonly method: string + readonly headers: readonly InspectorHeader[] + readonly hasBody: boolean + } + | NetworkEventBase & { + readonly type: 'response-received' + readonly url: string + readonly status: number + readonly statusText: string + readonly headers: readonly InspectorHeader[] + readonly mimeType: string + } + | NetworkEventBase & { + readonly type: 'response-data' + readonly data: string + readonly byteLength: number + } + | NetworkEventBase & { + readonly type: 'request-finished' + readonly encodedDataLength: number + readonly truncated: boolean + } + | NetworkEventBase & { + readonly type: 'request-failed' + readonly errorText: string + readonly canceled: boolean + } + | { readonly type: 'request-evicted'; readonly requestKey: string } + +type ReplayableNetworkEvent = Exclude + +interface CapturedRequest { + readonly key: string + readonly requestId: string + readonly sourceId: string + readonly requestBody: Buffer[] + readonly responseBody: Buffer[] + requestBodyBytes: number + responseBodyBytes: number + requestBodyTruncated: boolean + responseBodyTruncated: boolean + requestCaptureError?: string + responseCaptureError?: string + responseSeen: boolean + completed: boolean +} + +/** Validated Network observation store independent of CDP connection state. */ +export class NetworkStore implements InspectorRecordConsumer { + readonly topics = new Set(FETCH_TOPICS) + private readonly requests = new Map() + private readonly journal: ReplayableNetworkEvent[] = [] + private readonly completed: string[] = [] + private readonly listeners = new Set<(event: NetworkStoreEvent) => void>() + private journalBytes = 0 + + constructor(private readonly options: NetworkStoreOptions) {} + + replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void { + this.close(source, 'source state replaced') + this.append(source, records) + } + + append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void { + for (const record of records) { + if (!this.topics.has(record.topic)) continue + try { + this.ingest(source, record) + } catch { + // A malformed domain payload loses only that observation; later records remain independently useful. + } + } + } + + close(source: InspectorSourceDescriptor, reason: string): void { + for (const request of this.requests.values()) { + if (request.sourceId !== source.sourceId || request.completed) continue + request.completed = true + this.publish({ + type: 'request-failed', + requestKey: request.key, + requestId: request.requestId, + timestampMs: performance.timeOrigin + performance.now(), + errorText: reason, + canceled: true, + }) + this.completed.push(request.key) + } + this.enforceRetention() + } + + /** + * Read retained request lifecycle events. + * @returns Events in observation order. + */ + replay(): readonly ReplayableNetworkEvent[] { + return this.journal + } + + /** + * Subscribe to live request changes and eviction. + * @param listener - Consumer called synchronously after each accepted change. + * @returns A disposer removing the consumer. + */ + subscribe(listener: (event: NetworkStoreEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Read one retained request body. + * @param requestId - Public request id assigned by this store. + * @returns Captured bytes and truncation metadata. + */ + requestBody(requestId: unknown): CapturedNetworkBody { + const request = this.requestById(requestId) + return body(request.requestBody, request.requestBodyTruncated, request.requestCaptureError, request.completed) + } + + /** + * Read one retained response body after response headers have arrived. + * @param requestId - Public request id assigned by this store. + * @returns Captured bytes and truncation metadata. + */ + responseBody(requestId: unknown): CapturedNetworkBody { + const request = this.requestById(requestId) + if (!request.responseSeen) throw new Error('response headers have not arrived') + return body(request.responseBody, request.responseBodyTruncated, request.responseCaptureError, request.completed) + } + + /** Release subscribers and all retained request data. */ + dispose(): void { + this.listeners.clear() + this.requests.clear() + this.journal.length = 0 + this.completed.length = 0 + this.journalBytes = 0 + } + + private ingest(source: InspectorSourceDescriptor, record: IngestedInspectorRecord): void { + const payload = requirePayload(record.payload) + const localId = stringField(payload, 'requestId') + const key = `${source.sourceId}:${source.generation}:${localId}` + const timestampMs = source.timeOriginMs + record.monotonicMs + if (record.topic === 'fetch/start') { + if (this.requests.has(key)) throw new Error('fetch observation reused an active request id') + const request: CapturedRequest = { + key, + requestId: key, + sourceId: source.sourceId, + requestBody: [], + responseBody: [], + requestBodyBytes: 0, + responseBodyBytes: 0, + requestBodyTruncated: false, + responseBodyTruncated: false, + responseSeen: false, + completed: false, + } + this.requests.set(key, request) + this.publish({ + type: 'request-started', + requestKey: key, + requestId: request.requestId, + timestampMs, + wallTimeMs: numberField(payload, 'wallTimeMs'), + url: stringField(payload, 'url'), + method: stringField(payload, 'method'), + headers: headerField(payload, 'headers'), + hasBody: booleanField(payload, 'hasBody'), + }) + this.enforceRetention() + return + } + const request = this.requests.get(key) + if (request === undefined) return + switch (record.topic) { + case 'fetch/request-body-chunk': + this.appendBody(request, 'request', stringField(payload, 'data')) + return + case 'fetch/request-body-end': { + request.requestBodyTruncated ||= booleanField(payload, 'truncated') + const captureError = optionalStringField(payload, 'captureError') + if (captureError !== undefined) request.requestCaptureError = captureError + return + } + case 'fetch/response': + request.responseSeen = true + this.publish({ + type: 'response-received', + requestKey: key, + requestId: request.requestId, + timestampMs, + url: stringField(payload, 'url'), + status: numberField(payload, 'status'), + statusText: stringField(payload, 'statusText'), + headers: headerField(payload, 'headers'), + mimeType: stringField(payload, 'mimeType'), + }) + return + case 'fetch/response-body-chunk': { + const data = stringField(payload, 'data') + const byteLength = this.appendBody(request, 'response', data) + this.emit({ type: 'response-data', requestKey: key, requestId: request.requestId, timestampMs, data, byteLength }) + return + } + case 'fetch/end': { + request.responseBodyTruncated ||= booleanField(payload, 'responseBodyTruncated') + const captureError = optionalStringField(payload, 'responseCaptureError') + if (captureError !== undefined) request.responseCaptureError = captureError + this.complete(request, { + type: 'request-finished', + requestKey: key, + requestId: request.requestId, + timestampMs, + encodedDataLength: request.responseBodyBytes, + truncated: request.responseBodyTruncated, + }) + return + } + case 'fetch/error': + this.complete(request, { + type: 'request-failed', + requestKey: key, + requestId: request.requestId, + timestampMs, + errorText: stringField(payload, 'message'), + canceled: booleanField(payload, 'canceled'), + }) + return + default: + return + } + } + + private appendBody(request: CapturedRequest, side: 'request' | 'response', encoded: string): number { + const bytes = decodeBase64(encoded) + this.evictCompletedFor(bytes.byteLength, request.key) + const retained = bytes.subarray(0, Math.max(0, this.options.maxJournalBytes - this.journalBytes)) + if (side === 'request') { + if (retained.byteLength > 0) request.requestBody.push(retained) + request.requestBodyBytes += retained.byteLength + request.requestBodyTruncated ||= retained.byteLength < bytes.byteLength + } else { + if (retained.byteLength > 0) request.responseBody.push(retained) + request.responseBodyBytes += retained.byteLength + request.responseBodyTruncated ||= retained.byteLength < bytes.byteLength + } + this.journalBytes += retained.byteLength + this.enforceRetention() + return bytes.byteLength + } + + private complete(request: CapturedRequest, event: ReplayableNetworkEvent): void { + if (request.completed) return + request.completed = true + this.publish(event) + this.completed.push(request.key) + this.enforceRetention() + } + + private publish(event: ReplayableNetworkEvent): void { + this.journal.push(event) + this.emit(event) + } + + private emit(event: NetworkStoreEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One presentation adapter cannot interrupt repository ingestion or sibling consumers. + } + } + } + + private enforceRetention(): void { + while (this.requests.size > this.options.maxRetainedRequests || this.journalBytes > this.options.maxJournalBytes) { + const key = this.completed.shift() ?? this.oldestActiveRequestKey() + if (key === undefined) return + const request = this.requests.get(key) + if (request === undefined) continue + if (!request.completed) { + request.completed = true + this.publish({ + type: 'request-failed', + requestKey: request.key, + requestId: request.requestId, + timestampMs: performance.timeOrigin + performance.now(), + errorText: 'Inspector retained-request limit exceeded', + canceled: true, + }) + } + this.evict(request) + } + } + + private oldestActiveRequestKey(): string | undefined { + for (const request of this.requests.values()) { + if (!request.completed) return request.key + } + return undefined + } + + private evictCompletedFor(bytes: number, protectedKey: string): void { + while (this.journalBytes + bytes > this.options.maxJournalBytes) { + const index = this.completed.findIndex(key => key !== protectedKey) + if (index === -1) return + const [key] = this.completed.splice(index, 1) + if (key === undefined) return + const request = this.requests.get(key) + if (request !== undefined) this.evict(request) + } + } + + private evict(request: CapturedRequest): void { + this.journalBytes -= request.requestBodyBytes + request.responseBodyBytes + this.requests.delete(request.key) + for (let index = this.journal.length - 1; index >= 0; index--) { + if (this.journal[index]?.requestKey === request.key) this.journal.splice(index, 1) + } + this.emit({ type: 'request-evicted', requestKey: request.key }) + } + + private requestById(value: unknown): CapturedRequest { + if (typeof value !== 'string') throw new Error('Network requestId must be a string') + const request = [...this.requests.values()].find(candidate => candidate.requestId === value) + if (request === undefined) throw new Error(`No resource with given identifier: ${value}`) + return request + } +} + +function body( + chunks: readonly Buffer[], + truncated: boolean, + captureError: string | undefined, + complete: boolean, +): CapturedNetworkBody { + return { + bytes: Buffer.concat(chunks), + truncated, + complete, + ...(captureError === undefined ? {} : { captureError }), + } +} + +function decodeBase64(value: string): Buffer { + if (value.length === 0 || value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value)) { + throw new Error('fetch payload body chunk must be canonical base64') + } + const bytes = Buffer.from(value, 'base64') + if (bytes.toString('base64') !== value) throw new Error('fetch payload body chunk must be canonical base64') + return bytes +} + +function requirePayload(value: unknown): Readonly> { + if (!isPlainObject(value)) throw new Error('fetch payload must be an object') + return value +} + +function stringField(value: Readonly>, name: string): string { + const field = value[name] + if (typeof field !== 'string') throw new Error(`fetch payload ${name} must be a string`) + return field +} + +function optionalStringField(value: Readonly>, name: string): string | undefined { + const field = value[name] + if (field !== undefined && typeof field !== 'string') throw new Error(`fetch payload ${name} must be a string`) + return field +} + +function numberField(value: Readonly>, name: string): number { + const field = value[name] + if (typeof field !== 'number' || !Number.isFinite(field)) throw new Error(`fetch payload ${name} must be finite`) + return field +} + +function booleanField(value: Readonly>, name: string): boolean { + const field = value[name] + if (typeof field !== 'boolean') throw new Error(`fetch payload ${name} must be boolean`) + return field +} + +function headerField(value: Readonly>, name: string): InspectorHeader[] { + const field = value[name] + if (!Array.isArray(field)) throw new Error(`fetch payload ${name} must be a header list`) + return field.map((entry) => { + if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== 'string' || typeof entry[1] !== 'string') { + throw new Error(`fetch payload ${name} contains an invalid header`) + } + return [entry[0], entry[1]] as const + }) +} diff --git a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts new file mode 100644 index 0000000000..cabeb5a1a5 --- /dev/null +++ b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts @@ -0,0 +1,124 @@ +/** Host fetch observation behavior. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { installFetchObserver, type FetchObserver } from '../src/host/inspection/network.ts' +import type { InspectorRecordInput } from '../src/shared/bridge/messages/observation.ts' +import type { InspectorJsonValue } from '../src/shared/json.ts' + +describe('full fetch observer', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'fetch') + let observer: FetchObserver | undefined + + afterEach(async () => { + await observer?.stop() + observer = undefined + if (originalDescriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch') + else Object.defineProperty(globalThis, 'fetch', originalDescriptor) + }) + + it('captures complete URL, headers, request body, response headers, and response body', async () => { + const records: InspectorRecordInput[] = [] + const native = vi.fn(async (request: Request) => { + expect(await request.clone().text()).toBe('secret request body') + return new Response('complete response body', { + status: 201, + statusText: 'Created', + headers: { authorization: 'response secret', 'content-type': 'text/plain' }, + }) + }) + Object.defineProperty(globalThis, 'fetch', { value: native, writable: true, configurable: true }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + const response = await fetch('https://example.test/path?token=visible', { + method: 'POST', + headers: { authorization: 'Bearer visible' }, + body: 'secret request body', + }) + expect(await response.text()).toBe('complete response body') + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) + + const start = payload(records, 'fetch/start') + expect(start).toMatchObject({ + url: 'https://example.test/path?token=visible', + method: 'POST', + }) + expect(start.headers).toEqual(expect.arrayContaining([['authorization', 'Bearer visible']])) + expect(decodeChunks(records, 'fetch/request-body-chunk')).toBe('secret request body') + const responseRecord = payload(records, 'fetch/response') + expect(responseRecord.status).toBe(201) + expect(responseRecord.headers).toEqual(expect.arrayContaining([['authorization', 'response secret']])) + expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('complete response body') + expect(payload(records, 'fetch/request-body-end')).toMatchObject({ truncated: false }) + expect(payload(records, 'fetch/end')).toMatchObject({ responseBodyTruncated: false }) + }) + + it('marks bodies truncated without changing the caller response', async () => { + const records: InspectorRecordInput[] = [] + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.resolve(new Response('response-long'))), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 4, maxResponseBodyBytes: 4, maxChunkBytes: 2 }) + + const response = await fetch('https://example.test/', { method: 'POST', body: 'request-long' }) + expect(await response.text()).toBe('response-long') + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) + + expect(decodeChunks(records, 'fetch/request-body-chunk')).toBe('requ') + expect(payload(records, 'fetch/request-body-end')).toMatchObject({ capturedBytes: 4, truncated: true }) + expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('resp') + expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true }) + }) + + it('reports cancellation after response headers as a canceled request', async () => { + const records: InspectorRecordInput[] = [] + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(async (request: Request) => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from('first')) + request.signal.addEventListener('abort', () => { + controller.error(new DOMException('aborted', 'AbortError')) + }, { once: true }) + }, + }))), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + const abort = new AbortController() + + const response = await fetch('https://example.test/cancel-body', { signal: abort.signal }) + abort.abort() + await expect(response.text()).rejects.toThrow() + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/error')).toBe(true) }) + + expect(payload(records, 'fetch/error')).toMatchObject({ canceled: true }) + expect(records.some(record => record.topic === 'fetch/end')).toBe(false) + }) +}) + +function payload(records: readonly InspectorRecordInput[], topic: string): Record { + const record = records.find(candidate => candidate.topic === topic) + expect(record).toBeDefined() + return record!.payload as Record +} + +function decodeChunks(records: readonly InspectorRecordInput[], topic: string): string { + return Buffer.concat(records + .filter(record => record.topic === topic) + .map(record => Buffer.from(String((record.payload as Record).data), 'base64'))) + .toString('utf8') +} diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts new file mode 100644 index 0000000000..05273e38e1 --- /dev/null +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -0,0 +1,153 @@ +/** Worker-side Network projection behavior. */ + +import { describe, expect, it, vi } from 'vitest' +import { NetworkDomain, type NetworkSink } from '../src/worker/cdp/domains/network/session.ts' +import { NetworkStore } from '../src/worker/inspection/network-store.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' +import type { IngestedInspectorRecord } from '../src/worker/bridge/hub.ts' + +const source: InspectorSourceDescriptor = { + sourceId: inspectorId<'InspectorSourceId'>('host-network', 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>('network-generation', 'generation'), + kind: 'host', + label: 'Host', + timeOriginMs: performance.timeOrigin, + capabilities: [], +} + +describe('Inspector Network domain', () => { + it('bounds incomplete bodies and marks the retained prefix truncated', () => { + const sendEvent = vi.fn() + const sink: NetworkSink = { sendEvent } + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 4 }) + const network = new NetworkDomain(store) + network.enable(sink) + store.append(source, requestRecords('first', 'abcdef')) + + const response = network.handle('Network.getResponseBody', { requestId: requestId('first') }, sink) + expect(response).toEqual({ + body: Buffer.from('abcd').toString('base64'), + base64Encoded: true, + dshInspectorTruncated: true, + }) + const dataEvent = sendEvent.mock.calls.find(call => call[0] === 'Network.dataReceived') + expect(dataEvent?.[1]).toMatchObject({ dataLength: 6, encodedDataLength: 6 }) + expect(dataEvent?.[1]).not.toHaveProperty('data') + }) + + it('evicts completed requests before retaining a later body', () => { + const sink: NetworkSink = { sendEvent: vi.fn() } + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 4 }) + const network = new NetworkDomain(store) + store.append(source, requestRecords('first', 'aaaa')) + store.append(source, requestRecords('second', 'bbbb')) + + expect(() => network.handle('Network.getResponseBody', { requestId: requestId('first') }, sink)).toThrow( + 'No resource with given identifier', + ) + expect(network.handle('Network.getResponseBody', { requestId: requestId('second') }, sink)).toEqual({ + body: Buffer.from('bbbb').toString('base64'), + base64Encoded: true, + dshInspectorTruncated: false, + }) + }) + + it('streams later response chunks only to CDP sessions that opted in', () => { + const firstSend = vi.fn() + const secondSend = vi.fn() + const first: NetworkSink = { sendEvent: firstSend } + const second: NetworkSink = { sendEvent: secondSend } + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const network = new NetworkDomain(store) + network.enable(first) + network.enable(second) + const records = requestRecords('stream', 'data: first\n\n') + store.append(source, records.slice(0, 2)) + + expect(network.handle('Network.streamResourceContent', { requestId: requestId('stream') }, first)).toEqual({ + bufferedData: '', + }) + store.append(source, records.slice(2, 3)) + + const firstData = firstSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived') + const secondData = secondSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived') + expect(firstData?.[1]).toMatchObject({ data: Buffer.from('data: first\n\n').toString('base64') }) + expect(secondData?.[1]).not.toHaveProperty('data') + expect(network.handle('Network.streamResourceContent', { requestId: requestId('stream') }, second)).toEqual({ + bufferedData: Buffer.from('data: first\n\n').toString('base64'), + }) + + const later = Buffer.from('data: second\n\n').toString('base64') + store.append(source, [{ + sequence: 4, + monotonicMs: 4, + topic: 'fetch/response-body-chunk', + payload: { requestId: 'stream', data: later }, + }]) + expect(firstSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')?.[1]).toMatchObject({ data: later }) + expect(secondSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')?.[1]).toMatchObject({ data: later }) + }) + + it('bounds active request metadata and does not retain per-chunk events for replay', () => { + const firstSend = vi.fn() + const store = new NetworkStore({ maxRetainedRequests: 1, maxJournalBytes: 1_024 }) + const network = new NetworkDomain(store) + network.enable({ sendEvent: firstSend }) + store.append(source, requestRecords('active-first', 'first').slice(0, 1)) + store.append(source, requestRecords('active-second', 'second').slice(0, 1)) + + expect(firstSend).toHaveBeenCalledWith('Network.loadingFailed', expect.objectContaining({ + requestId: requestId('active-first'), + canceled: true, + })) + expect(() => network.handle( + 'Network.getRequestPostData', + { requestId: requestId('active-first') }, + { sendEvent: vi.fn() }, + )).toThrow('No resource with given identifier') + expect(() => { store.append(source, requestRecords('active-first', 'first').slice(1)) }).not.toThrow() + + store.append(source, requestRecords('active-second', 'second').slice(1)) + const replay = vi.fn() + network.enable({ sendEvent: replay }) + expect(replay.mock.calls.some(call => call[0] === 'Network.dataReceived')).toBe(false) + expect(replay).toHaveBeenCalledTimes(3) + expect(replay).toHaveBeenNthCalledWith(1, 'Network.requestWillBeSent', expect.any(Object)) + expect(replay).toHaveBeenNthCalledWith(2, 'Network.responseReceived', expect.any(Object)) + expect(replay).toHaveBeenNthCalledWith(3, 'Network.loadingFinished', expect.any(Object)) + }) +}) + +function requestRecords(localId: string, body: string): IngestedInspectorRecord[] { + return [ + { + sequence: 1, + monotonicMs: 1, + topic: 'fetch/start', + payload: { requestId: localId, url: 'https://example.test/', method: 'GET', headers: [], hasBody: false, wallTimeMs: 1 }, + }, + { + sequence: 2, + monotonicMs: 2, + topic: 'fetch/response', + payload: { requestId: localId, url: 'https://example.test/', status: 200, statusText: 'OK', headers: [], mimeType: 'text/plain' }, + }, + { + sequence: 3, + monotonicMs: 3, + topic: 'fetch/response-body-chunk', + payload: { requestId: localId, data: Buffer.from(body).toString('base64') }, + }, + { + sequence: 4, + monotonicMs: 4, + topic: 'fetch/end', + payload: { requestId: localId, capturedBytes: body.length, responseBodyTruncated: false }, + }, + ] +} + +function requestId(localId: string): string { + return `${source.sourceId}:${source.generation}:${localId}` +} From 28cc3e930b84f83bc72da79532e963d3574c3f2a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:26:52 +0800 Subject: [PATCH 067/130] feat(inspector): expose Cordis trees through CDP DOM --- .../inspector/src/client/inspection/cordis.ts | 25 + .../inspector/src/host/inspection/cordis.ts | 25 + .../src/shared/bridge/messages/cordis.ts | 4 + .../src/shared/bridge/messages/query/codec.ts | 138 ++++++ .../shared/bridge/messages/query/commands.ts | 40 ++ .../shared/bridge/messages/query/frames.ts | 30 ++ .../src/shared/bridge/messages/query/index.ts | 5 + .../src/shared/bridge/query-reader.ts | 18 + .../inspector/src/shared/cordis/collector.ts | 195 ++++++++ .../inspector/src/shared/cordis/ids.ts | 9 + .../inspector/src/shared/cordis/model.ts | 161 +++++++ .../src/shared/cordis/object-reference.ts | 23 + .../src/shared/cordis/object-registry.ts | 176 +++++++ .../inspector/src/shared/cordis/observer.ts | 46 ++ .../inspector/src/shared/cordis/projector.ts | 88 ++++ .../inspector/src/shared/cordis/reader.ts | 24 + .../inspector/src/shared/cordis/snapshot.ts | 111 +++++ .../inspector/src/shared/service.ts | 32 ++ .../src/worker/cdp/domains/dom/index.ts | 4 + .../src/worker/cdp/domains/dom/model.ts | 213 ++++++++ .../src/worker/cdp/domains/dom/session.ts | 361 ++++++++++++++ .../src/worker/inspection/cordis-query.ts | 17 + .../src/worker/inspection/cordis-store.ts | 248 ++++++++++ .../src/worker/inspection/query-router.ts | 255 ++++++++++ .../inspector/tests/cordis-query.host.spec.ts | 349 ++++++++++++++ .../inspector/tests/cordis-tree.host.spec.ts | 454 ++++++++++++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 73 +++ scripts/gen-cordis-catalog.ts | 2 + 28 files changed, 3126 insertions(+) create mode 100644 packages/experimental/inspector/src/client/inspection/cordis.ts create mode 100644 packages/experimental/inspector/src/host/inspection/cordis.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/cordis.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/messages/query/index.ts create mode 100644 packages/experimental/inspector/src/shared/bridge/query-reader.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/collector.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/ids.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/model.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/object-reference.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/object-registry.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/observer.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/projector.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/reader.ts create mode 100644 packages/experimental/inspector/src/shared/cordis/snapshot.ts create mode 100644 packages/experimental/inspector/src/shared/service.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts create mode 100644 packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/cordis-query.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/cordis-store.ts create mode 100644 packages/experimental/inspector/src/worker/inspection/query-router.ts create mode 100644 packages/experimental/inspector/tests/cordis-query.host.spec.ts create mode 100644 packages/experimental/inspector/tests/cordis-tree.host.spec.ts diff --git a/packages/experimental/inspector/src/client/inspection/cordis.ts b/packages/experimental/inspector/src/client/inspection/cordis.ts new file mode 100644 index 0000000000..4492aa2e1b --- /dev/null +++ b/packages/experimental/inspector/src/client/inspection/cordis.ts @@ -0,0 +1,25 @@ +/** Browser adapter that publishes shared Cordis snapshots over the Client bridge. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { CordisTreeLimits } from '../../shared/cordis/collector.ts' +import { observeCordisTree } from '../../shared/cordis/observer.ts' +import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' + +/** + * Observe the Client Cordis runtime and retain its latest bridge snapshot. + * @param ctx - Client plugin context whose root is inspected. + * @param publisher - Active Client bridge publisher. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that stops observation and releases retained objects. + */ +export function publishCordisTree( + ctx: Context, + publisher: InspectorStatePublisher, + limits: CordisTreeLimits, +): () => void { + return observeCordisTree(ctx, (snapshot) => { + publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) + }, limits) +} diff --git a/packages/experimental/inspector/src/host/inspection/cordis.ts b/packages/experimental/inspector/src/host/inspection/cordis.ts new file mode 100644 index 0000000000..871b234d5e --- /dev/null +++ b/packages/experimental/inspector/src/host/inspection/cordis.ts @@ -0,0 +1,25 @@ +/** Host adapter that publishes shared Cordis snapshots over the Host bridge. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { CordisTreeLimits } from '../../shared/cordis/collector.ts' +import { observeCordisTree } from '../../shared/cordis/observer.ts' +import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' +import type { InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' + +/** + * Observe the Host Cordis runtime and retain its latest bridge snapshot. + * @param ctx - Host plugin context whose root is inspected. + * @param publisher - Active Host bridge publisher. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that stops observation and releases retained objects. + */ +export function publishCordisTree( + ctx: Context, + publisher: InspectorStatePublisher, + limits: CordisTreeLimits, +): () => void { + return observeCordisTree(ctx, (snapshot) => { + publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) + }, limits) +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/cordis.ts b/packages/experimental/inspector/src/shared/bridge/messages/cordis.ts new file mode 100644 index 0000000000..7b51cf5b70 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/cordis.ts @@ -0,0 +1,4 @@ +/** Bridge message metadata for Cordis runtime-tree snapshots. */ + +/** Observation topic carrying the latest complete Cordis tree. */ +export const CORDIS_TREE_TOPIC = 'cordis/tree' diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts new file mode 100644 index 0000000000..967753bf60 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts @@ -0,0 +1,138 @@ +/** Exact decoders for non-CDP Inspector query frames. */ + +import { parseCordisRuntimeTree } from '../../../cordis/model.ts' +import { isPlainObject } from '../../../json.ts' +import { exactKeys, exactObject, wireId } from '../../../validation.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts' +import type { + InspectorQueryRequestFrame, + InspectorQueryRequestId, + InspectorQueryResponseFrame, +} from './frames.ts' +import type { InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts' + +/** Correlation fields recoverable before a query body is accepted. */ +export interface InspectorQueryFrameIdentity { + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly requestId: InspectorQueryRequestId +} + +/** + * Test whether a decoded carrier value belongs to the query request protocol. + * @param value - Decoded carrier value. + * @returns Whether the query request decoder owns the value. + */ +export function isInspectorQueryRequestEnvelope(value: unknown): boolean { + return isPlainObject(value) && value.t === 'query/request' +} + +/** + * Test whether a decoded carrier value belongs to the query response protocol. + * @param value - Decoded carrier value. + * @returns Whether the query response decoder owns the value. + */ +export function isInspectorQueryResponseEnvelope(value: unknown): boolean { + return isPlainObject(value) && value.t === 'query/response' +} + +/** + * Decode one source-to-Worker query request. + * @param value - Untrusted decoded carrier value. + * @returns The detached, validated request frame. + */ +export function parseInspectorQueryRequestFrame(value: unknown): InspectorQueryRequestFrame { + const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'query'], 'query request') + if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/request') { + throw new Error('inspector protocol: invalid query request envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/request', + sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'), + requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'), + query: parseQuery(record.query), + } +} + +/** + * Decode correlation fields used to reject a malformed request without timing out its caller. + * @param value - Candidate query request frame. + * @returns Validated source and request identities. + */ +export function parseInspectorQueryFrameIdentity(value: unknown): InspectorQueryFrameIdentity { + if (!isPlainObject(value) || value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'query/request') { + throw new Error('inspector protocol: invalid query request envelope') + } + return { + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + requestId: wireId<'InspectorQueryRequestId'>(value.requestId, 'requestId'), + } +} + +/** + * Decode one Worker-to-source query response. + * @param value - Untrusted decoded carrier value. + * @returns The detached, validated response frame. + */ +export function parseInspectorQueryResponseFrame(value: unknown): InspectorQueryResponseFrame { + const record = exactObject(value, ['v', 't', 'sourceId', 'generation', 'requestId', 'outcome'], 'query response') + if (record.v !== INSPECTOR_PROTOCOL_VERSION || record.t !== 'query/response') { + throw new Error('inspector protocol: invalid query response envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/response', + sourceId: wireId<'InspectorSourceId'>(record.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(record.generation, 'generation'), + requestId: wireId<'InspectorQueryRequestId'>(record.requestId, 'requestId'), + outcome: parseOutcome(record.outcome), + } +} + +function parseQuery(value: unknown): InspectorQuery { + const record = exactObject(value, ['op'], 'Inspector query') + if (record.op !== 'cordis-tree/get') { + throw new Error(`inspector protocol: unknown query operation ${JSON.stringify(record.op)}`) + } + return { op: 'cordis-tree/get' } +} + +function parseResult(value: unknown): InspectorQueryResult { + if (!isPlainObject(value) || typeof value.op !== 'string') { + throw new Error('inspector protocol: query result must have an op') + } + switch (value.op) { + case 'cordis-tree/get': + exactKeys(value, ['op', 'tree'], 'Cordis tree query result') + return { op: 'cordis-tree/get', tree: parseCordisRuntimeTree(value.tree) } + default: + throw new Error(`inspector protocol: unknown query result ${JSON.stringify(value.op)}`) + } +} + +function parseOutcome(value: unknown): InspectorQueryResponseFrame['outcome'] { + if (!isPlainObject(value) || typeof value.ok !== 'boolean') { + throw new Error('inspector protocol: invalid query outcome') + } + if (value.ok) { + exactKeys(value, ['ok', 'result'], 'successful query outcome') + return { ok: true, result: parseResult(value.result) } + } + exactKeys(value, ['ok', 'error'], 'failed query outcome') + const error = exactObject(value.error, ['code', 'message'], 'query error') + if (!QUERY_ERROR_CODES.has(error.code as InspectorQueryError['code']) || typeof error.message !== 'string') { + throw new Error('inspector protocol: invalid query error') + } + return { + ok: false, + error: { code: error.code as InspectorQueryError['code'], message: error.message }, + } +} + +const QUERY_ERROR_CODES = new Set([ + 'invalid-request', 'stale-source', 'result-too-large', 'internal-error', +]) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts new file mode 100644 index 0000000000..f2f626cd2d --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/commands.ts @@ -0,0 +1,40 @@ +/** Closed non-CDP Inspector query and result model. */ + +import type { CordisRuntimeTree } from '../../../cordis/model.ts' + +/** Read the latest committed Cordis runtime tree. */ +export interface CordisTreeGetQuery { + readonly op: 'cordis-tree/get' +} + +/** Query operations accepted by the Inspector Worker. */ +export type InspectorQuery = CordisTreeGetQuery + +/** Result of reading the latest committed Cordis runtime tree. */ +export interface CordisTreeGetResult { + readonly op: 'cordis-tree/get' + readonly tree: CordisRuntimeTree +} + +/** Results correlated to {@link InspectorQuery} by `op`. */ +export type InspectorQueryResult = CordisTreeGetResult + +/** Result member corresponding to one query member. */ +export type InspectorQueryResultFor = Extract + +/** Stable Worker-side query failure. */ +export interface InspectorQueryError { + readonly code: 'invalid-request' | 'stale-source' | 'result-too-large' | 'internal-error' + readonly message: string +} + +/** Host/Client interface implemented by the shared correlated-query owner. */ +export interface InspectorQueryRequester { + /** + * Execute one query against the current connected source generation. + * @param query - Closed typed query command. + * @returns The result with the same operation discriminant. + * @throws When transport or Worker processing cannot settle the request successfully. + */ + request(query: Query): Promise> +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts new file mode 100644 index 0000000000..7a864878f8 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/frames.ts @@ -0,0 +1,30 @@ +/** Versioned frames for source-to-Worker non-CDP queries. */ + +import type { InspectorId, InspectorSourceGeneration, InspectorSourceId } from '../../ids.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../version.ts' +import type { InspectorQuery, InspectorQueryError, InspectorQueryResult } from './commands.ts' + +/** Identity of one in-flight Inspector query. */ +export type InspectorQueryRequestId = InspectorId<'InspectorQueryRequestId'> + +/** Source request for one Worker-owned query operation. */ +export interface InspectorQueryRequestFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'query/request' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly requestId: InspectorQueryRequestId + readonly query: InspectorQuery +} + +/** Worker response correlated to one source query request. */ +export interface InspectorQueryResponseFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'query/response' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly requestId: InspectorQueryRequestId + readonly outcome: + | { readonly ok: true; readonly result: InspectorQueryResult } + | { readonly ok: false; readonly error: InspectorQueryError } +} diff --git a/packages/experimental/inspector/src/shared/bridge/messages/query/index.ts b/packages/experimental/inspector/src/shared/bridge/messages/query/index.ts new file mode 100644 index 0000000000..e8bd0c0311 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/messages/query/index.ts @@ -0,0 +1,5 @@ +/** Public exports for the non-CDP Inspector query protocol. */ + +export * from './codec.ts' +export * from './commands.ts' +export * from './frames.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/query-reader.ts b/packages/experimental/inspector/src/shared/bridge/query-reader.ts new file mode 100644 index 0000000000..464d088756 --- /dev/null +++ b/packages/experimental/inspector/src/shared/bridge/query-reader.ts @@ -0,0 +1,18 @@ +/** Query-backed adapter for the transport-independent Cordis tree reader. */ + +import type { CordisRuntimeTreeReader } from '../cordis/reader.ts' +import type { InspectorQueryRequester } from './messages/query/commands.ts' + +/** + * Create a reader that obtains the tree through the typed Inspector query protocol. + * @param requester - Active Host or Client query connection. + * @returns A non-CDP Cordis tree reader. + */ +export function createQueryCordisRuntimeTreeReader(requester: InspectorQueryRequester): CordisRuntimeTreeReader { + return { + async getTree() { + const result = await requester.request({ op: 'cordis-tree/get' }) + return result.tree + }, + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/collector.ts b/packages/experimental/inspector/src/shared/cordis/collector.ts new file mode 100644 index 0000000000..622e4d6158 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/collector.ts @@ -0,0 +1,195 @@ +/** Shared Host/Client projection from live Cordis objects to a bounded semantic tree. */ + +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { jsonByteLength, type InspectorJsonValue } from '../json.ts' +import { + CORDIS_TREE_SCHEMA_VERSION, + type CordisContextTreeNode, + type CordisFiberTreeNode, + type CordisTreeSnapshot, +} from './snapshot.ts' +import type { InspectorObjectHandle } from './ids.ts' +import { RealmObjectRegistry } from './object-registry.ts' + +const SHADOW = Symbol.for('cordis.shadow') + +/** Bounds applied before one snapshot enters a source frame. */ +export interface CordisTreeLimits { + readonly maxNodes: number + readonly maxBytes: number +} + +interface ContextInfo { + readonly value: Context + readonly children: ContextInfo[] + readonly fiber: Fiber | undefined +} + +interface MutableContextNode extends Omit { + readonly children: MutableTreeNode[] +} + +interface MutableFiberNode extends Omit { + readonly children: [MutableContextNode] +} + +type MutableTreeNode = MutableContextNode | MutableFiberNode + +/** Realm-local collector with a current live-object table. */ +export class CordisTreeCollector { + /** Live-object table replaced atomically with each emitted snapshot. */ + readonly objects = new RealmObjectRegistry() + private revision = 0 + + constructor(private readonly root: Context, private readonly limits: CordisTreeLimits) {} + + /** + * Capture the current reachable Context/Fiber tree. + * @returns A detached JSON snapshot whose retained objects replace the prior generation atomically. + */ + snapshot(): CordisTreeSnapshot { + const tree = collectContexts(this.root) + const objects = this.objects.begin() + let nodeCount = 0 + let truncated = false + + const contextNode = (info: ContextInfo): MutableContextNode | undefined => { + if (nodeCount >= this.limits.maxNodes) { + truncated = true + return undefined + } + nodeCount++ + const node: MutableContextNode = { + kind: 'context', + objectHandle: objects.retain(info.value).handle, + children: [], + } + for (const child of info.children) { + if (child.fiber !== undefined && child.fiber.ctx === child.value) { + const projected = fiberNode(child.fiber, child) + if (projected !== undefined) node.children.push(projected) + } else { + const projected = contextNode(child) + if (projected !== undefined) node.children.push(projected) + } + } + return node + } + const fiberNode = (fiber: Fiber, owned: ContextInfo): MutableFiberNode | undefined => { + if (fiber.uid === null) return undefined + if (nodeCount + 2 > this.limits.maxNodes) { + truncated = true + return undefined + } + nodeCount++ + const context = contextNode(owned) + if (context === undefined) throw new Error('inspector: reserved Fiber Context was not collected') + return { + kind: 'fiber', + objectHandle: objects.retain(fiber).handle, + uid: fiber.uid, + children: [context], + } + } + + const root = contextNode(tree) + if (root === undefined) throw new Error('inspector: maxNodes cannot retain the root Context') + let snapshot: CordisTreeSnapshot = { + schemaVersion: CORDIS_TREE_SCHEMA_VERSION, + revision: ++this.revision, + objectRegistryId: this.objects.id, + root, + truncated, + } + while (jsonByteLength(snapshot as unknown as InspectorJsonValue) > this.limits.maxBytes) { + const removed = pruneLast(root) + if (removed.length === 0) break + for (const handle of removed) objects.release(handle) + snapshot = { ...snapshot, truncated: true } + } + if (jsonByteLength(snapshot as unknown as InspectorJsonValue) > this.limits.maxBytes) { + throw new Error('inspector: Cordis root exceeds the source-frame byte limit') + } + objects.commit() + return snapshot + } + + /** Release the realm-global resolver and every retained object. */ + close(): void { + this.objects.close() + } +} + +function collectContexts(root: Context): ContextInfo { + const contexts = new Map() + const ensure = (candidate: unknown, depth = 0): ContextInfo | undefined => { + if (depth > 100) return undefined + const value = unwrapContext(candidate) + if (!Context.is(value)) return undefined + const existing = contexts.get(value) + if (existing !== undefined) return existing + if (value === root) { + const info = describeContext(value) + contexts.set(value, info) + return info + } + const prototype = unwrapContext(Object.getPrototypeOf(value) as unknown) + const parent = ensure(prototype, depth + 1) + if (parent === undefined) return undefined + const info = describeContext(value) + contexts.set(value, info) + parent.children.push(info) + return info + } + + const rootInfo = ensure(root) + if (rootInfo === undefined) throw new Error('inspector: Cordis root context is not reachable') + for (const runtime of root.registry.values()) { + for (const fiber of runtime.fibers) { + if (fiber.uid === null) continue + ensure(fiber.parent) + ensure(fiber.ctx) + } + } + for (const key of Reflect.ownKeys(root.events._hooks)) { + for (const hook of root.events._hooks[key] ?? []) ensure(hook.ctx) + } + const order = (info: ContextInfo): number => info.fiber?.uid ?? Number.MAX_SAFE_INTEGER + for (const info of contexts.values()) { + info.children.sort((left, right) => order(left) - order(right)) + } + return rootInfo +} + +function describeContext(value: Context): ContextInfo { + const fiber = ownValue(value, 'fiber') as Fiber | undefined + return { value, children: [], fiber } +} + +function ownValue(value: object, key: PropertyKey): unknown { + return Reflect.getOwnPropertyDescriptor(value, key)?.value +} + +function unwrapContext(value: unknown): unknown { + let current = value + while (typeof current === 'object' && current !== null && Object.hasOwn(current, SHADOW)) { + current = Object.getPrototypeOf(current) + } + return current +} + +function pruneLast(context: MutableContextNode): InspectorObjectHandle[] { + const child = context.children.at(-1) + if (child === undefined) return [] + if (child.kind === 'context') { + const nested = pruneLast(child) + if (nested.length > 0) return nested + context.children.pop() + return [child.objectHandle] + } + const owned = child.children[0] + const nested = pruneLast(owned) + if (nested.length > 0) return nested + context.children.pop() + return [child.objectHandle, owned.objectHandle] +} diff --git a/packages/experimental/inspector/src/shared/cordis/ids.ts b/packages/experimental/inspector/src/shared/cordis/ids.ts new file mode 100644 index 0000000000..6cfcd27e39 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/ids.ts @@ -0,0 +1,9 @@ +/** Opaque identifiers owned by a realm-local Cordis object registry. */ + +import type { InspectorId } from '../identity.ts' + +/** Identity of one realm-local table that retains objects named in a snapshot. */ +export type InspectorObjectRegistryId = InspectorId<'InspectorObjectRegistryId'> + +/** Opaque reference to one object retained by a realm-local registry. */ +export type InspectorObjectHandle = InspectorId<'InspectorObjectHandle'> diff --git a/packages/experimental/inspector/src/shared/cordis/model.ts b/packages/experimental/inspector/src/shared/cordis/model.ts new file mode 100644 index 0000000000..86750e508d --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/model.ts @@ -0,0 +1,161 @@ +/** Consumer-neutral Cordis runtime tree shared by non-CDP readers. */ + +import { CORDIS_TREE_MAX_DEPTH } from './snapshot.ts' +import { inspectorId, type InspectorId } from '../identity.ts' +import { isPlainObject } from '../json.ts' +import { exactKeys, exactObject, wireId } from '../validation.ts' + +/** Current consumer-neutral Cordis tree version. */ +export const CORDIS_RUNTIME_TREE_SCHEMA_VERSION = 0 as const + +/** Consumer-visible identity of one inspected Cordis runtime. */ +export type CordisRuntimeSourceId = InspectorId<'CordisRuntimeSourceId'> + +/** Execution environment represented by one consumer-visible Cordis runtime. */ +export type CordisRuntimeSourceKind = 'host' | 'client' + +/** Availability of the realm represented by a retained tree. */ +export type CordisRuntimeConnection = + | { readonly state: 'connected' } + | { readonly state: 'disconnected'; readonly reason: string } + +/** Consumer-visible identity of one Cordis realm. */ +export interface CordisRuntimeSource { + readonly sourceId: CordisRuntimeSourceId + readonly kind: CordisRuntimeSourceKind + readonly label: string +} + +/** One Context in a consumer-neutral Cordis tree. */ +export interface CordisRuntimeContext { + readonly kind: 'context' + readonly children: readonly CordisRuntimeNode[] +} + +/** One Fiber and its owned Context in a consumer-neutral Cordis tree. */ +export interface CordisRuntimeFiber { + readonly kind: 'fiber' + readonly uid: number + readonly children: readonly [CordisRuntimeContext] +} + +/** One semantic Cordis runtime node. */ +export type CordisRuntimeNode = CordisRuntimeContext | CordisRuntimeFiber + +/** Latest retained topology and availability of one Cordis realm. */ +export interface CordisRuntimeRealm { + readonly source: CordisRuntimeSource + readonly connection: CordisRuntimeConnection + readonly revision: number + readonly truncated: boolean + readonly root: CordisRuntimeContext +} + +/** Latest Host and Client Cordis topology without routing or CDP identifiers. */ +export interface CordisRuntimeTree { + readonly schemaVersion: typeof CORDIS_RUNTIME_TREE_SCHEMA_VERSION + readonly host: CordisRuntimeRealm | null + readonly clients: readonly CordisRuntimeRealm[] +} + +/** + * Decode a consumer-neutral tree received across an Inspector transport. + * @param value - Untrusted query result value. + * @returns A detached tree containing only public semantic fields. + */ +export function parseCordisRuntimeTree(value: unknown): CordisRuntimeTree { + const record = exactObject(value, ['schemaVersion', 'host', 'clients'], 'Cordis runtime tree') + if (record.schemaVersion !== CORDIS_RUNTIME_TREE_SCHEMA_VERSION || !Array.isArray(record.clients)) { + throw new Error('inspector protocol: invalid Cordis runtime tree') + } + const host = record.host === null ? null : parseRealm(record.host, 'host') + const clients = record.clients.map(client => parseRealm(client, 'client')) + const sourceIds = new Set() + for (const realm of host === null ? clients : [host, ...clients]) { + if (sourceIds.has(realm.source.sourceId)) { + throw new Error('inspector protocol: Cordis runtime tree repeats a sourceId') + } + sourceIds.add(realm.source.sourceId) + } + return { + schemaVersion: CORDIS_RUNTIME_TREE_SCHEMA_VERSION, + host, + clients, + } +} + +function parseRealm(value: unknown, kind: CordisRuntimeSourceKind): CordisRuntimeRealm { + const record = exactObject(value, ['source', 'connection', 'revision', 'truncated', 'root'], 'Cordis runtime realm') + const source = exactObject(record.source, ['sourceId', 'kind', 'label'], 'Cordis runtime source') + if (source.kind !== kind || typeof source.label !== 'string' || source.label.length === 0 || source.label.length > 256) { + throw new Error(`inspector protocol: invalid ${kind} Cordis runtime source`) + } + if (!Number.isSafeInteger(record.revision) || (record.revision as number) < 1 || typeof record.truncated !== 'boolean') { + throw new Error('inspector protocol: invalid Cordis runtime realm header') + } + const root = parseNode(record.root, { fiberUids: new Set() }, 0) + if (root.kind !== 'context') throw new Error('inspector protocol: Cordis runtime root must be a Context') + return { + source: { + sourceId: wireId<'CordisRuntimeSourceId'>(source.sourceId, 'sourceId'), + kind, + label: source.label, + }, + connection: parseConnection(record.connection), + revision: record.revision as number, + truncated: record.truncated, + root, + } +} + +/** + * Project an inspected source id into the consumer-visible Cordis identity namespace. + * @param value - Stable source id carried by the current runtime observation. + * @returns The corresponding Cordis runtime source id. + */ +export function cordisRuntimeSourceId(value: string): CordisRuntimeSourceId { + return inspectorId<'CordisRuntimeSourceId'>(value, 'sourceId') +} + +function parseConnection(value: unknown): CordisRuntimeConnection { + if (!isPlainObject(value)) throw new Error('inspector protocol: Cordis runtime connection must be an object') + if (value.state === 'connected') { + exactKeys(value, ['state'], 'connected Cordis runtime connection') + return { state: 'connected' } + } + if (value.state === 'disconnected' && typeof value.reason === 'string') { + exactKeys(value, ['state', 'reason'], 'disconnected Cordis runtime connection') + return { state: 'disconnected', reason: value.reason } + } + throw new Error('inspector protocol: invalid Cordis runtime connection') +} + +interface ParseState { + readonly fiberUids: Set +} + +function parseNode(value: unknown, state: ParseState, depth: number): CordisRuntimeNode { + if (depth > CORDIS_TREE_MAX_DEPTH) throw new Error('inspector protocol: Cordis runtime tree exceeds the depth limit') + if (!isPlainObject(value) || (value.kind !== 'context' && value.kind !== 'fiber')) { + throw new Error('inspector protocol: Cordis runtime node must have a known kind') + } + const record = exactObject(value, value.kind === 'fiber' + ? ['kind', 'uid', 'children'] + : ['kind', 'children'], 'Cordis runtime node') + if (!Array.isArray(record.children)) throw new Error('inspector protocol: Cordis runtime node children must be an array') + if (record.kind === 'context') { + return { kind: 'context', children: record.children.map(child => parseNode(child, state, depth + 1)) } + } + if (record.kind !== 'fiber' + || !Number.isSafeInteger(record.uid) + || (record.uid as number) < 1 + || record.children.length !== 1) { + throw new Error('inspector protocol: invalid Cordis runtime Fiber') + } + const uid = record.uid as number + if (state.fiberUids.has(uid)) throw new Error('inspector protocol: Cordis runtime tree repeats a Fiber uid') + state.fiberUids.add(uid) + const context = parseNode(record.children[0], state, depth + 1) + if (context.kind !== 'context') throw new Error('inspector protocol: Cordis runtime Fiber child must be a Context') + return { kind: 'fiber', uid, children: [context] } +} diff --git a/packages/experimental/inspector/src/shared/cordis/object-reference.ts b/packages/experimental/inspector/src/shared/cordis/object-reference.ts new file mode 100644 index 0000000000..756414252f --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/object-reference.ts @@ -0,0 +1,23 @@ +/** Opaque references to live objects retained inside an observation source realm. */ + +import type { InspectorObjectHandle, InspectorObjectRegistryId } from './ids.ts' +import { exactObject, wireId } from '../validation.ts' + +/** Wire-safe identity of one live object; the source generation supplies the realm identity. */ +export interface InspectorObjectReference { + readonly registryId: InspectorObjectRegistryId + readonly handle: InspectorObjectHandle +} + +/** + * Decode one source-local live-object reference. + * @param value - Untrusted wire value. + * @returns The validated opaque reference. + */ +export function parseInspectorObjectReference(value: unknown): InspectorObjectReference { + const record = exactObject(value, ['registryId', 'handle'], 'object reference') + return { + registryId: wireId<'InspectorObjectRegistryId'>(record.registryId, 'registryId'), + handle: wireId<'InspectorObjectHandle'>(record.handle, 'handle'), + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/object-registry.ts b/packages/experimental/inspector/src/shared/cordis/object-registry.ts new file mode 100644 index 0000000000..c36c171c1e --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/object-registry.ts @@ -0,0 +1,176 @@ +/** Realm-local retention and identity for live objects referenced by Inspector snapshots. */ + +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { inspectorId } from '../identity.ts' +import { + type InspectorObjectHandle, + type InspectorObjectRegistryId, +} from './ids.ts' +import type { InspectorObjectReference } from './object-reference.ts' + +const REGISTRIES_SYMBOL = 'dsh.inspector.realm-object-registries' +const MAX_FIBER_WRAPPER_DEPTH = 8 + +/** Self-contained function sent through CDP to identify its `this` object in the inspected realm. */ +export const IDENTIFY_REALM_OBJECT_FUNCTION = `function () { + const table = globalThis[Symbol.for(${JSON.stringify(REGISTRIES_SYMBOL)})] + if (!(table instanceof Map)) return undefined + for (const registry of table.values()) { + const reference = registry.identify(this) + if (reference !== undefined) return reference + } + return undefined +}` + +/** One realm's bounded table of objects retained by its latest semantic snapshot. */ +export class RealmObjectRegistry { + /** Realm-unique id carried by every reference from this registry. */ + readonly id = inspectorId<'InspectorObjectRegistryId'>(randomUUID(), 'registryId') + private readonly known = new WeakMap() + private retained = new Map() + private nextHandle = 1 + private disposed = false + + constructor() { + registries().set(this.id, this) + } + + /** + * Start one replacement generation. + * @returns A collector that atomically installs exactly the retained objects on commit. + */ + begin(): RealmObjectGeneration { + if (this.disposed) throw new Error('inspector: realm object registry is disposed') + return new RealmObjectGeneration(this) + } + + /** + * Resolve one current opaque handle. + * @param handle - Handle from the latest committed snapshot. + * @returns The live object, when it remains retained. + */ + resolve(handle: InspectorObjectHandle): object | undefined { + return this.retained.get(handle) + } + + /** + * Identify one object retained by the latest snapshot. Cordis plugin calls may return nested thenable facades; + * only objects whose prototype path consists exclusively of those `then` wrappers resolve to the retained Fiber. + * @param value - Candidate live value. + * @returns Its wire reference, when present in this registry. + */ + identify(value: unknown): InspectorObjectReference | undefined { + if ((typeof value !== 'object' || value === null) && typeof value !== 'function') return undefined + let candidate: object | null = value + for (let depth = 0; candidate !== null && depth <= MAX_FIBER_WRAPPER_DEPTH; depth++) { + const handle = this.known.get(candidate) + if (handle !== undefined && this.retained.get(handle) === candidate) return { registryId: this.id, handle } + try { + const keys = Reflect.ownKeys(candidate) + if (keys.length !== 1 || keys[0] !== 'then') return undefined + candidate = Object.getPrototypeOf(candidate) as object | null + } catch { + // A hostile proxy cannot prevent later registries from checking the original value. + return undefined + } + } + return undefined + } + + /** Remove this registry from the realm and release all strong references. */ + close(): void { + if (this.disposed) return + this.disposed = true + registries().delete(this.id) + this.retained.clear() + } + + /** + * Assign a stable handle and retain a value in one pending generation. + * @param value - Object represented by the pending snapshot. + * @param next - Pending generation's strong-reference table. + * @returns The registry id and stable object handle. + */ + retain(value: object, next: Map): InspectorObjectReference { + let handle = this.known.get(value) + if (handle === undefined) { + handle = inspectorId<'InspectorObjectHandle'>(`object-${String(this.nextHandle++)}`, 'objectHandle') + this.known.set(value, handle) + } + next.set(handle, value) + return { registryId: this.id, handle } + } + + /** + * Replace the current strong-reference set with one completed generation. + * @param next - Complete object table for the committed snapshot. + */ + commit(next: Map): void { + this.retained = next + } +} + +/** Mutable object set assembled before one snapshot becomes visible. */ +export class RealmObjectGeneration { + private readonly retained = new Map() + private committed = false + + constructor(private readonly owner: RealmObjectRegistry) {} + + /** + * Retain one object and obtain its stable opaque reference. + * @param value - Context or Fiber represented in the snapshot. + * @returns Source-local wire reference. + */ + retain(value: object): InspectorObjectReference { + if (this.committed) throw new Error('inspector: realm object generation is already committed') + return this.owner.retain(value, this.retained) + } + + /** + * Stop retaining an object omitted while bounding the pending snapshot. + * @param handle - Opaque handle removed from this pending generation. + */ + release(handle: InspectorObjectHandle): void { + if (this.committed) throw new Error('inspector: realm object generation is already committed') + this.retained.delete(handle) + } + + /** Atomically replace the registry's retained set. */ + commit(): void { + if (this.committed) return + this.committed = true + this.owner.commit(this.retained) + } +} + +/** + * Build an expression that resolves one reference inside its owning realm. + * @param reference - Validated source-local object reference. + * @returns Side-effect-free JavaScript expression for Runtime evaluation. + */ +export function realmObjectExpression(reference: InspectorObjectReference): string { + return `globalThis[Symbol.for(${JSON.stringify(REGISTRIES_SYMBOL)})]?.get(${JSON.stringify(reference.registryId)})?.resolve(${JSON.stringify(reference.handle)})` +} + +/** + * Identify a retained object across all Inspector collectors in this realm. + * @param value - Runtime value returned to a debugger. + * @returns Its source-local reference, when the value is a visible entity. + */ +export function identifyRealmObject(value: unknown): InspectorObjectReference | undefined { + for (const registry of registries().values()) { + const reference = registry.identify(value) + if (reference !== undefined) return reference + } + return undefined +} + +function registries(): Map { + const key = Symbol.for(REGISTRIES_SYMBOL) + const existing = Reflect.get(globalThis, key) as unknown + if (existing instanceof Map) return existing as Map + const value = new Map() + Reflect.set(globalThis, key, value) + return value +} diff --git a/packages/experimental/inspector/src/shared/cordis/observer.ts b/packages/experimental/inspector/src/shared/cordis/observer.ts new file mode 100644 index 0000000000..00793a3739 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/observer.ts @@ -0,0 +1,46 @@ +/** Lifecycle-driven Cordis tree publication shared by Host and Client plugin faces. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { CordisTreeSnapshot } from './snapshot.ts' +import { CordisTreeCollector, type CordisTreeLimits } from './collector.ts' + +/** Receives one complete semantic snapshot after a coalesced Cordis mutation. */ +export type CordisTreeSnapshotListener = (snapshot: CordisTreeSnapshot) => void + +/** + * Observe one Cordis realm and publish immutable tree replacements. + * @param ctx - Plugin context whose root is inspected and whose effects own listeners. + * @param listener - Consumer of complete snapshots in the inspected realm. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that unregisters listeners and releases retained objects. + */ +export function observeCordisTree( + ctx: Context, + listener: CordisTreeSnapshotListener, + limits: CordisTreeLimits, +): () => void { + const collector = new CordisTreeCollector(ctx.root, limits) + let scheduled = false + let closed = false + const publish = (): void => { + scheduled = false + if (closed) return + listener(collector.snapshot()) + } + const schedule = (): void => { + if (scheduled || closed) return + scheduled = true + queueMicrotask(publish) + } + const disposers = [ + ctx.on('internal/plugin', schedule, { global: true }), + ctx.on('internal/status', schedule, { global: true }), + ] + publish() + return () => { + if (closed) return + closed = true + for (const dispose of disposers) dispose() + collector.close() + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/projector.ts b/packages/experimental/inspector/src/shared/cordis/projector.ts new file mode 100644 index 0000000000..d0d02efe77 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/projector.ts @@ -0,0 +1,88 @@ +/** Pure projection from routed Cordis snapshots to the consumer-neutral tree. */ + +import type { CordisTreeNode, CordisTreeSnapshot } from './snapshot.ts' +import { + CORDIS_RUNTIME_TREE_SCHEMA_VERSION, + cordisRuntimeSourceId, + type CordisRuntimeContext, + type CordisRuntimeNode, + type CordisRuntimeSourceKind, + type CordisRuntimeTree, +} from './model.ts' + +/** Whether a retained routed snapshot still has a live source generation. */ +export type CordisTreeSourceConnection = + | { readonly state: 'connected' } + | { readonly state: 'disconnected'; readonly reason: string } + +/** One source generation and its latest routed Cordis snapshot. */ +export interface CordisTreeSource { + readonly sourceId: string + readonly kind: CordisRuntimeSourceKind + readonly label: string +} + +/** One source generation and its latest routed Cordis snapshot. */ +export interface CordisTreeSourceSnapshot { + readonly source: Source + readonly snapshot: CordisTreeSnapshot + readonly connection: CordisTreeSourceConnection +} + +/** Routed Host and Client snapshots before consumer-neutral projection. */ +export interface CordisInspectionTree { + readonly host: CordisTreeSourceSnapshot | null + readonly clients: readonly CordisTreeSourceSnapshot[] +} + +/** + * Strip transport and live-object routing fields from retained Cordis snapshots. + * @param tree - Worker-owned routed snapshots. + * @returns A detached semantic tree safe for non-CDP consumers. + */ +export function projectCordisRuntimeTree(tree: CordisInspectionTree): CordisRuntimeTree { + return { + schemaVersion: CORDIS_RUNTIME_TREE_SCHEMA_VERSION, + host: tree.host === null ? null : projectRealm(tree.host), + clients: tree.clients.map(projectRealm), + } +} + +function projectRealm(realm: CordisTreeSourceSnapshot): CordisRuntimeTree['clients'][number] { + return { + source: { + sourceId: cordisRuntimeSourceId(realm.source.sourceId), + kind: realm.source.kind, + label: realm.source.label, + }, + connection: realm.connection.state === 'connected' + ? { state: 'connected' } + : { state: 'disconnected', reason: realm.connection.reason }, + revision: realm.snapshot.revision, + truncated: realm.snapshot.truncated, + root: projectContext(realm.snapshot.root), + } +} + +function projectContext(node: Extract): CordisRuntimeContext { + return { kind: 'context', children: node.children.map(projectNode) } +} + +function projectNode(node: CordisTreeNode): CordisRuntimeNode { + switch (node.kind) { + case 'context': + return projectContext(node) + case 'fiber': + return { + kind: 'fiber', + uid: node.uid, + children: [projectContext(node.children[0])], + } + default: + return assertNever(node) + } +} + +function assertNever(value: never): never { + throw new Error(`Unexpected Cordis tree node: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/src/shared/cordis/reader.ts b/packages/experimental/inspector/src/shared/cordis/reader.ts new file mode 100644 index 0000000000..f335cb484d --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/reader.ts @@ -0,0 +1,24 @@ +/** Environment-independent Cordis runtime tree reader. */ + +import type { CordisRuntimeTree } from './model.ts' + +/** Read-only access to the latest committed consumer-neutral Cordis tree. */ +export interface CordisRuntimeTreeReader { + /** + * Read the latest Worker snapshot without activating CDP domains. + * @returns A detached Host and Client Cordis tree. + * @throws When the source transport is unavailable, closes, times out, or rejects the query. + */ + getTree(): Promise +} + +/** + * Create a reader around a local committed-tree projection. + * @param read - Synchronous or asynchronous latest-tree read. + * @returns A reader suitable for query and CDP adapters. + */ +export function createCordisRuntimeTreeReader( + read: () => CordisRuntimeTree | Promise, +): CordisRuntimeTreeReader { + return { getTree: async () => await read() } +} diff --git a/packages/experimental/inspector/src/shared/cordis/snapshot.ts b/packages/experimental/inspector/src/shared/cordis/snapshot.ts new file mode 100644 index 0000000000..ba0dcfa8e8 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/snapshot.ts @@ -0,0 +1,111 @@ +/** CDP-independent snapshot model for a Cordis Context and Fiber tree. */ + +import { + type InspectorObjectHandle, + type InspectorObjectRegistryId, +} from './ids.ts' +import { isPlainObject } from '../json.ts' +import { exactKeys, exactObject, wireId } from '../validation.ts' + +/** Current serialized Cordis tree model version. */ +export const CORDIS_TREE_SCHEMA_VERSION = 0 as const + +/** Maximum nesting accepted from one realm snapshot. */ +export const CORDIS_TREE_MAX_DEPTH = 256 + +interface CordisTreeNodeBase { + readonly objectHandle: InspectorObjectHandle +} + +/** One Context entity in a Cordis tree snapshot. */ +export interface CordisContextTreeNode extends CordisTreeNodeBase { + readonly kind: 'context' + readonly children: readonly CordisTreeNode[] +} + +/** One Fiber entity in a Cordis tree snapshot. */ +export interface CordisFiberTreeNode extends CordisTreeNodeBase { + readonly kind: 'fiber' + readonly uid: number + readonly children: readonly [CordisContextTreeNode] +} + +/** One semantic entity node in preorder. */ +export type CordisTreeNode = CordisContextTreeNode | CordisFiberTreeNode + +/** Immutable, serializable state of one realm's reachable Cordis tree. */ +export interface CordisTreeSnapshot { + readonly schemaVersion: typeof CORDIS_TREE_SCHEMA_VERSION + readonly revision: number + readonly objectRegistryId: InspectorObjectRegistryId + readonly root: CordisContextTreeNode + readonly truncated: boolean +} + +/** + * Decode and validate one complete Cordis tree replacement. + * @param value - Untrusted observation payload. + * @param maxNodes - Maximum nodes admitted from one source. + * @returns A detached, validated snapshot. + */ +export function parseCordisTreeSnapshot(value: unknown, maxNodes: number): CordisTreeSnapshot { + const record = exactObject(value, [ + 'schemaVersion', 'revision', 'objectRegistryId', 'root', 'truncated', + ], 'Cordis tree') + if (record.schemaVersion !== CORDIS_TREE_SCHEMA_VERSION + || !Number.isSafeInteger(record.revision) || (record.revision as number) < 1 + || typeof record.truncated !== 'boolean') { + throw new Error('inspector protocol: invalid Cordis tree header') + } + const state: ParseState = { count: 0, handles: new Set(), fiberUids: new Set() } + const root = parseNode(record.root, state, maxNodes, 0) + if (root.kind !== 'context') throw new Error('inspector protocol: Cordis tree root must be a Context') + return { + schemaVersion: CORDIS_TREE_SCHEMA_VERSION, + revision: record.revision as number, + objectRegistryId: wireId<'InspectorObjectRegistryId'>(record.objectRegistryId, 'objectRegistryId'), + root, + truncated: record.truncated, + } +} + +interface ParseState { + count: number + readonly handles: Set + readonly fiberUids: Set +} + +function parseNode(value: unknown, state: ParseState, maxNodes: number, depth: number): CordisTreeNode { + if (depth > CORDIS_TREE_MAX_DEPTH) throw new Error('inspector protocol: Cordis tree exceeds the depth limit') + if (++state.count > maxNodes) throw new Error(`inspector protocol: Cordis tree exceeds ${String(maxNodes)} nodes`) + if (!isPlainObject(value) || (value.kind !== 'context' && value.kind !== 'fiber')) { + throw new Error('inspector protocol: Cordis tree node must have a known kind') + } + const objectHandle = wireId<'InspectorObjectHandle'>(value.objectHandle, 'objectHandle') + if (state.handles.has(objectHandle)) throw new Error('inspector protocol: Cordis tree repeats an object handle') + state.handles.add(objectHandle) + if (!Array.isArray(value.children)) throw new Error('inspector protocol: Cordis tree node children must be an array') + if (value.kind === 'context') { + exactKeys(value, ['kind', 'objectHandle', 'children'], 'Context tree node') + return { + kind: 'context', + objectHandle, + children: value.children.map(child => parseNode(child, state, maxNodes, depth + 1)), + } + } + exactKeys(value, ['kind', 'objectHandle', 'uid', 'children'], 'Fiber tree node') + if (!Number.isSafeInteger(value.uid) || (value.uid as number) < 1) { + throw new Error('inspector protocol: Cordis Fiber uid must be a positive safe integer') + } + if (state.fiberUids.has(value.uid as number)) throw new Error('inspector protocol: Cordis tree repeats a Fiber uid') + state.fiberUids.add(value.uid as number) + if (value.children.length !== 1) throw new Error('inspector protocol: Cordis Fiber must own exactly one Context') + const context = parseNode(value.children[0], state, maxNodes, depth + 1) + if (context.kind !== 'context') throw new Error('inspector protocol: Cordis Fiber child must be a Context') + return { + kind: 'fiber', + objectHandle, + uid: value.uid as number, + children: [context], + } +} diff --git a/packages/experimental/inspector/src/shared/service.ts b/packages/experimental/inspector/src/shared/service.ts new file mode 100644 index 0000000000..0cd8bbae83 --- /dev/null +++ b/packages/experimental/inspector/src/shared/service.ts @@ -0,0 +1,32 @@ +/** Cordis service API shared by the Host and Client plugin faces. */ + +import type { CordisRuntimeTreeReader } from './cordis/reader.ts' +import { createQueryCordisRuntimeTreeReader } from './bridge/query-reader.ts' +import type { InspectorJsonValue } from './json.ts' +import type { InspectorConnection } from './bridge/publisher.ts' + +/** Shared Host/Client service façade over the realm's source publisher. */ +export interface InspectorService { + /** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void + + /** Read-only Cordis topology queries independent of CDP sessions. */ + readonly cordis: CordisRuntimeTreeReader +} + +/** + * Create the shared service façade without exposing the carrier implementation. + * @param connection - Realm-local observation and query transport. + * @returns The Cordis service value. + */ +export function createInspectorService(connection: InspectorConnection): InspectorService { + return { + publish: (topic, payload, monotonicMs) => { connection.publish(topic, payload, monotonicMs) }, + cordis: createQueryCordisRuntimeTreeReader(connection), + } +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts new file mode 100644 index 0000000000..d7288f784a --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts @@ -0,0 +1,4 @@ +/** Cordis semantic DOM domain exports. */ + +export { CordisDomBackend, type CordisDomChange } from './model.ts' +export { CordisDomSession } from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts new file mode 100644 index 0000000000..ad70430dab --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts @@ -0,0 +1,213 @@ +/** Worker projection from Cordis snapshots to a connection-neutral semantic DOM. */ + +import type { CordisTreeNode } from '../../../../shared/cordis/snapshot.ts' +import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts' +import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts' +import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts' +import { cdpNumericId, type CdpBackendNodeId } from '../../ids.ts' +import type { + CordisTreeObjectRoute, + CordisTreeSourceSnapshot, + CordisTreeStore, + CordisTreeStoreEvent, +} from '../../../inspection/cordis-store.ts' + +/** One Worker-global backend node independent of any DevTools connection. */ +export interface CordisDomNode { + readonly backendNodeId: CdpBackendNodeId + readonly key: string + readonly name: string + readonly attributes: readonly (readonly [string, string])[] + readonly description: string + readonly object?: CordisTreeObjectRoute + readonly children: readonly CordisDomNode[] +} + +/** Immutable document revision shared by all current DevTools sessions. */ +export interface CordisDomDocument { + readonly revision: number + readonly root: CordisDomNode + readonly byBackendId: ReadonlyMap + readonly parentByBackendId: ReadonlyMap +} + +/** A full tree replacement or an in-place source availability change. */ +export type CordisDomChange = + | { readonly type: 'document-updated' } + | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor } + +/** Assigns durable backend ids and projects the latest source snapshots. */ +export class CordisDomBackend { + private readonly backendIdByKey = new Map() + private readonly listeners = new Set<(event: CordisDomChange) => void>() + private documentValue: CordisDomDocument + private nextBackendNodeId = 1 + private nextRevision = 1 + private readonly unsubscribe: () => void + private readonly nodeByObject = new Map() + + constructor(private readonly trees: CordisTreeStore) { + this.documentValue = this.build() + this.unsubscribe = trees.subscribe((event) => { + const previous = this.documentValue + this.documentValue = this.build() + const change = this.change(event, previous) + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch { + // One closed CDP connection cannot prevent sibling sessions from receiving the new document. + } + } + }) + } + + /** + * Read the latest connection-neutral semantic document. + * @returns The current immutable document revision. + */ + document(): CordisDomDocument { + return this.documentValue + } + + /** + * Subscribe to full document replacements and in-place realm state changes. + * @param listener - Called after a new backend revision is installed. + * @returns A disposer removing the listener. + */ + subscribe(listener: (event: CordisDomChange) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** Release repository subscriptions at Worker shutdown. */ + close(): void { + this.unsubscribe() + this.listeners.clear() + } + + /** + * Resolve one source-local object reference to its current projected node. + * @param source - Connected source generation that owns the reference. + * @param reference - Realm-local registry and object handle. + * @returns The current projected node, when present. + */ + nodeForObject(source: InspectorSourceDescriptor, reference: InspectorObjectReference): CordisDomNode | undefined { + return this.nodeByObject.get(objectKey(source, reference)) + } + + /** + * Resolve a reference when a Runtime route identifies only Host or Client ownership. + * @param kind - Host or Client ownership inferred by the Runtime adapter. + * @param reference - Realm-local registry and object handle. + * @returns The current projected node, when present. + */ + nodeForObjectKind(kind: InspectorSourceDescriptor['kind'], reference: InspectorObjectReference): CordisDomNode | undefined { + const route = this.trees.resolveObjectInKind(kind, reference) + return route === undefined ? undefined : this.nodeForObject(route.source, reference) + } + + /** + * Resolve one realm-neutral Runtime reference to its current projected node. + * @param realm - Realm that exposed the Runtime object. + * @param reference - Realm-local registry and object handle. + * @returns The current projected node, when present. + */ + nodeForRealm(realm: InspectorRealmDescriptor, reference: InspectorObjectReference): CordisDomNode | undefined { + if (realm.kind === 'host') return this.nodeForObjectKind('host', reference) + const route = this.trees.resolveObjectIdentity(realm.sourceId, realm.generation, reference) + return route === undefined ? undefined : this.nodeForObject(route.source, reference) + } + + private build(): CordisDomDocument { + const byBackendId = new Map() + const parentByBackendId = new Map() + this.nodeByObject.clear() + const tree = this.trees.tree() + const root = this.node('document', '#document', [], '#document') + const host = this.node('host', 'host', [], '') + if (tree.host !== null) host.children.push(this.entity(tree.host, tree.host.snapshot.root)) + const clients = this.node('clients', 'clients', [], '') + for (const clientTree of tree.clients) { + const client = this.node(`client:${clientTree.source.sourceId}`, 'client', [], '') + client.children.push(this.entity(clientTree, clientTree.snapshot.root)) + clients.children.push(client) + } + root.children.push(host, clients) + const retainedKeys = new Set() + const freeze = (node: MutableDomNode, parent?: MutableDomNode): CordisDomNode => { + const value: CordisDomNode = { ...node, children: node.children.map(child => freeze(child, node)) } + retainedKeys.add(value.key) + byBackendId.set(value.backendNodeId, value) + if (parent !== undefined) parentByBackendId.set(value.backendNodeId, parent.backendNodeId) + if (value.object?.connection.state === 'connected') this.nodeByObject.set(objectKey(value.object.source, { + registryId: value.object.snapshot.objectRegistryId, + handle: value.object.node.objectHandle, + }), value) + return value + } + const frozenRoot = freeze(root) + for (const key of this.backendIdByKey.keys()) { + if (!retainedKeys.has(key)) this.backendIdByKey.delete(key) + } + return { revision: this.nextRevision++, root: frozenRoot, byBackendId, parentByBackendId } + } + + private entity( + tree: CordisTreeSourceSnapshot, + node: CordisTreeNode, + ): MutableDomNode { + const { source, snapshot } = tree + const key = `entity:${objectKey(source, { registryId: snapshot.objectRegistryId, handle: node.objectHandle })}` + const object = { ...tree, node } + const attributes: readonly (readonly [string, string])[] = node.kind === 'fiber' + ? [['uid', String(node.uid)]] + : [] + const projected = this.node(key, node.kind, attributes, elementDescription(node.kind, attributes), object) + projected.children.push(...node.children.map(child => this.entity(tree, child))) + return projected + } + + private node( + key: string, + name: string, + attributes: readonly (readonly [string, string])[], + description: string, + object?: CordisTreeObjectRoute, + ): MutableDomNode { + let backendNodeId = this.backendIdByKey.get(key) + if (backendNodeId === undefined) { + backendNodeId = cdpNumericId<'CdpBackendNodeId'>(this.nextBackendNodeId++, 'backendNodeId') + this.backendIdByKey.set(key, backendNodeId) + } + return { backendNodeId, key, name, attributes, description, ...(object === undefined ? {} : { object }), children: [] } + } + + private change(event: CordisTreeStoreEvent, previous: CordisDomDocument): CordisDomChange { + if (event.type === 'source-disconnected' && sameNodeSet(previous, this.documentValue)) { + return { type: 'source-disconnected', source: event.source } + } + return { type: 'document-updated' } + } +} + +interface MutableDomNode extends Omit { + readonly children: MutableDomNode[] +} + +function elementDescription(name: string, attributes: readonly (readonly [string, string])[]): string { + const rendered = attributes.map(([key, value]) => value === '' ? key : `${key}=${JSON.stringify(value)}`).join(' ') + return `<${name}${rendered === '' ? '' : ` ${rendered}`}>` +} + +function objectKey(source: InspectorSourceDescriptor, reference: InspectorObjectReference): string { + return `${source.sourceId}\0${source.generation}\0${reference.registryId}\0${reference.handle}` +} + +function sameNodeSet(left: CordisDomDocument, right: CordisDomDocument): boolean { + if (left.byBackendId.size !== right.byBackendId.size) return false + for (const backendNodeId of left.byBackendId.keys()) { + if (!right.byBackendId.has(backendNodeId)) return false + } + return true +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts new file mode 100644 index 0000000000..4f2e53181c --- /dev/null +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts @@ -0,0 +1,361 @@ +/** Per-DevTools-session read-only DOM projection over Cordis tree snapshots. */ + +import { realmObjectExpression } from '../../../../shared/cordis/object-registry.ts' +import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts' +import type { InspectorObjectReference } from '../../../../shared/cordis/object-reference.ts' +import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts' +import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts' +import type { RuntimeDomainSession } from '../runtime/index.ts' +import type { RuntimeObjectPresentation } from '../runtime/object-table.ts' +import type { CordisDomBackend, CordisDomChange, CordisDomNode } from './model.ts' +import { + cdpNumericId, + cdpStringId, + type CdpBackendNodeId, + type CdpNodeId, + type CdpRemoteObjectId, +} from '../../ids.ts' + +const READ_ONLY_METHODS = new Set([ + 'DOM.setAttributeValue', 'DOM.setAttributesAsText', 'DOM.setNodeName', 'DOM.setNodeValue', + 'DOM.setOuterHTML', 'DOM.removeNode', 'DOM.moveTo', 'DOM.copyTo', +]) + +interface BoundDomObject { + readonly backendNodeId: CdpBackendNodeId + readonly sourceId: string + readonly generation: string +} + +/** Connection-local NodeId, search, and RemoteObject mapping owner. */ +export class CordisDomSession { + private readonly nodeIdByBackend = new Map() + private readonly backendByNodeId = new Map() + private readonly backendByObjectId = new Map() + private readonly objectIdsByGroup = new Map>() + private readonly searches = new Map() + private readonly unsubscribe: () => void + private nextNodeId = 1 + private nextSearchId = 1 + private enabled = false + + constructor( + private readonly transport: CdpTransport, + private readonly backend: CordisDomBackend, + private readonly runtime: RuntimeDomainSession, + ) { + this.unsubscribe = backend.subscribe((event) => { this.updateDocument(event) }) + } + + /** + * Handle one DOM command. + * @param request - Parsed CDP request. + * @returns Whether this adapter owns the method. + */ + handle(request: CdpRequest): boolean { + if (!request.method.startsWith('DOM.')) return false + this.respond(request, async () => this.execute(request.method, request.params)) + return true + } + + /** + * Forget a Runtime object mapping before its owner releases the object. + * @param objectId - Connection-local Runtime object id. + */ + releaseObject(objectId: unknown): void { + if (typeof objectId !== 'string') return + const id = cdpStringId<'CdpRemoteObjectId'>(objectId, 'objectId') + this.backendByObjectId.delete(id) + for (const ids of this.objectIdsByGroup.values()) ids.delete(id) + } + + /** + * Recognize a Runtime object from any realm as one current Cordis node. + * @param objectId - Connection-local CDP object id. + * @param realm - Realm that exposed the object. + * @param reference - Realm-local semantic object identity. + * @param group - Runtime object group retaining the id. + * @returns Node presentation fields, when the object remains in the current tree. + */ + bindObject( + objectId: CdpRemoteObjectId, + realm: InspectorRealmDescriptor, + reference: InspectorObjectReference, + group: string | undefined, + ): RuntimeObjectPresentation | undefined { + const node = this.backend.nodeForRealm(realm, reference) + if (node === undefined) return undefined + this.bindObjectId(objectId, node, group) + return presentation(node) + } + + /** + * Forget every DOM mapping retained under one Runtime object group. + * @param group - Runtime object-group name. + */ + releaseObjectGroup(group: unknown): void { + if (typeof group !== 'string') return + for (const objectId of this.objectIdsByGroup.get(group) ?? []) this.backendByObjectId.delete(objectId) + this.objectIdsByGroup.delete(group) + } + + /** Release connection-owned ids and subscriptions. */ + close(): void { + this.unsubscribe() + this.resetDocument() + this.searches.clear() + } + + private async execute(method: string, params: Readonly>): Promise { + if (READ_ONLY_METHODS.has(method)) throw new Error('Cordis DOM projection is read-only') + switch (method) { + case 'DOM.enable': + this.enabled = true + return {} + case 'DOM.disable': + this.enabled = false + this.resetDocument() + return {} + case 'DOM.getDocument': + this.enabled = true + return { root: this.serialize(this.backend.document().root, 0, true) } + case 'DOM.requestChildNodes': { + const node = this.fromNodeId(params.nodeId) + this.transport.send({ + method: 'DOM.setChildNodes', + params: { + parentId: numberParam(params.nodeId, 'nodeId'), + nodes: node.children.map(child => this.serialize(child, this.nodeId(node), true)), + }, + }) + return {} + } + case 'DOM.describeNode': { + const node = this.selectNode(params) + return { node: this.serialize(node, this.parentNodeId(node), true) } + } + case 'DOM.getAttributes': + return { attributes: this.fromNodeId(params.nodeId).attributes.flat() } + case 'DOM.getOuterHTML': + return { outerHTML: outerHtml(this.selectNode(params)) } + case 'DOM.pushNodesByBackendIdsToFrontend': { + if (!Array.isArray(params.backendNodeIds)) throw new Error('backendNodeIds must be an array') + return { + nodeIds: params.backendNodeIds.map((value) => { + if (!Number.isSafeInteger(value) || (value as number) < 1) return 0 + const node = this.backend.document().byBackendId.get(cdpBackendNodeId(value, 'backendNodeId')) + return node === undefined ? 0 : this.nodeId(node) + }), + } + } + case 'DOM.resolveNode': + return { object: await this.resolveNode(this.selectNode(params), optionalString(params.objectGroup)) } + case 'DOM.requestNode': { + const objectId = cdpStringId<'CdpRemoteObjectId'>(stringParam(params.objectId, 'objectId'), 'objectId') + const binding = this.backendByObjectId.get(objectId) + if (binding === undefined) throw new Error('RemoteObject is not a current Cordis node') + const node = this.backend.document().byBackendId.get(binding.backendNodeId) + if (node === undefined) throw new Error('Cordis node is no longer available') + return { nodeId: this.nodeId(node) } + } + case 'DOM.performSearch': { + const query = stringParam(params.query, 'query').toLowerCase() + const nodes = [...this.backend.document().byBackendId.values()] + .filter(node => node.name !== '#document' && searchable(node).includes(query)) + .map(node => this.nodeId(node)) + const searchId = `cordis-search-${String(this.nextSearchId++)}` + this.searches.set(searchId, nodes) + return { searchId, resultCount: nodes.length } + } + case 'DOM.getSearchResults': { + const ids = this.searches.get(stringParam(params.searchId, 'searchId')) ?? [] + return { + nodeIds: ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex')), + } + } + case 'DOM.discardSearchResults': + this.searches.delete(stringParam(params.searchId, 'searchId')) + return {} + case 'DOM.setInspectedNode': + this.fromNodeId(params.nodeId) + return {} + case 'DOM.getBoxModel': + case 'DOM.getNodeForLocation': + throw new Error('Cordis semantic nodes do not have browser layout geometry') + default: + throw new Error(`Method not found: ${method}`) + } + } + + private async resolveNode(node: CordisDomNode, objectGroup: string | undefined): Promise>> { + const route = node.object + if (route === undefined) throw new Error('Structural Cordis node has no live Runtime object') + if (route.connection.state === 'disconnected') throw new Error('Cordis realm is disconnected') + const expression = realmObjectExpression({ + registryId: route.snapshot.objectRegistryId, + handle: route.node.objectHandle, + }) + const remote = await this.runtime.resolveObject(route.source, expression, objectGroup) + const rawObjectId = remote.objectId + if (typeof rawObjectId !== 'string') throw new Error('Cordis object lookup returned no RemoteObjectId') + const objectId = cdpStringId<'CdpRemoteObjectId'>(rawObjectId, 'objectId') + this.bindObjectId(objectId, node, objectGroup) + return { + ...remote, + ...presentation(node), + } + } + + private bindObjectId(objectId: CdpRemoteObjectId, node: CordisDomNode, group: string | undefined): void { + const source = node.object?.source + if (source === undefined) throw new Error('Structural Cordis node cannot bind a Runtime object') + this.backendByObjectId.set(objectId, { + backendNodeId: node.backendNodeId, + sourceId: source.sourceId, + generation: source.generation, + }) + if (group === undefined) return + let ids = this.objectIdsByGroup.get(group) + if (ids === undefined) this.objectIdsByGroup.set(group, ids = new Set()) + ids.add(objectId) + } + + private selectNode(params: Readonly>): CordisDomNode { + if (params.nodeId !== undefined) return this.fromNodeId(params.nodeId) + if (params.backendNodeId !== undefined) { + const id = cdpBackendNodeId(params.backendNodeId, 'backendNodeId') + const node = this.backend.document().byBackendId.get(id) + if (node !== undefined) return node + } + if (typeof params.objectId === 'string') { + const binding = this.backendByObjectId.get(cdpStringId<'CdpRemoteObjectId'>(params.objectId, 'objectId')) + const node = binding === undefined + ? undefined + : this.backend.document().byBackendId.get(binding.backendNodeId) + if (node !== undefined) return node + } + throw new Error('Cordis node is not available') + } + + private fromNodeId(value: unknown): CordisDomNode { + const backendId = this.backendByNodeId.get(cdpNodeId(value, 'nodeId')) + const node = backendId === undefined ? undefined : this.backend.document().byBackendId.get(backendId) + if (node === undefined) throw new Error('Cordis NodeId is not available in this document') + return node + } + + private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, children: boolean): object { + const nodeId = this.nodeId(node) + const document = node.name === '#document' + return { + nodeId, + backendNodeId: node.backendNodeId, + nodeType: document ? 9 : 1, + nodeName: document ? '#document' : node.name.toUpperCase(), + localName: document ? '' : node.name, + nodeValue: '', + ...(parentId === 0 ? {} : { parentId }), + ...(document ? { documentURL: 'dsh://cordis', baseURL: 'dsh://cordis' } : {}), + childNodeCount: node.children.length, + ...(children ? { children: node.children.map(child => this.serialize(child, nodeId, true)) } : {}), + attributes: node.attributes.flat(), + } + } + + private nodeId(node: CordisDomNode): CdpNodeId { + let nodeId = this.nodeIdByBackend.get(node.backendNodeId) + if (nodeId === undefined) { + nodeId = cdpNumericId<'CdpNodeId'>(this.nextNodeId++, 'nodeId') + this.nodeIdByBackend.set(node.backendNodeId, nodeId) + this.backendByNodeId.set(nodeId, node.backendNodeId) + } + return nodeId + } + + private parentNodeId(node: CordisDomNode): CdpNodeId | 0 { + const parent = this.backend.document().parentByBackendId.get(node.backendNodeId) + if (parent === undefined) return 0 + const nodeValue = this.backend.document().byBackendId.get(parent) + return nodeValue === undefined ? 0 : this.nodeId(nodeValue) + } + + private resetDocument(): void { + this.nodeIdByBackend.clear() + this.backendByNodeId.clear() + this.backendByObjectId.clear() + this.objectIdsByGroup.clear() + this.searches.clear() + } + + private updateDocument(event: CordisDomChange): void { + if (event.type === 'source-disconnected') { + this.releaseSourceObjects(event.source) + return + } + this.resetDocument() + if (this.enabled) this.transport.send({ method: 'DOM.documentUpdated', params: {} }) + } + + private releaseSourceObjects(source: InspectorSourceDescriptor): void { + for (const [objectId, binding] of this.backendByObjectId) { + if (binding.sourceId !== source.sourceId || binding.generation !== source.generation) continue + this.backendByObjectId.delete(objectId) + for (const [group, objectIds] of this.objectIdsByGroup) { + objectIds.delete(objectId) + if (objectIds.size === 0) this.objectIdsByGroup.delete(group) + } + } + } + + private respond(request: CdpRequest, operation: () => Promise): void { + respondToCdpRequest(this.transport, request, operation) + } +} + +function outerHtml(node: CordisDomNode, indent = ''): string { + const attributes = node.attributes.map(([name, value]) => ` ${name}=${JSON.stringify(value)}`).join('') + if (node.children.length === 0) return `${indent}<${node.name}${attributes} />` + const children = node.children.map(child => outerHtml(child, `${indent} `)).join('\n') + return `${indent}<${node.name}${attributes}>\n${children}\n${indent}` +} + +function searchable(node: CordisDomNode): string { + return `${node.name} ${node.description} ${node.attributes.flat().join(' ')}`.toLowerCase() +} + +function numberParam(value: unknown, name: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) throw new Error(`${name} must be a non-negative integer`) + return value as number +} + +function cdpNodeId(value: unknown, name: string): CdpNodeId { + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`) + return cdpNumericId<'CdpNodeId'>(value as number, name) +} + +function cdpBackendNodeId(value: unknown, name: string): CdpBackendNodeId { + if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`) + return cdpNumericId<'CdpBackendNodeId'>(value as number, name) +} + +function nonNegativeInteger(value: unknown, name: string): number { + return numberParam(value, name) +} + +function stringParam(value: unknown, name: string): string { + if (typeof value !== 'string') throw new Error(`${name} must be a string`) + return value +} + +function optionalString(value: unknown): string | undefined { + if (value === undefined) return undefined + return stringParam(value, 'objectGroup') +} + +function presentation(node: CordisDomNode): RuntimeObjectPresentation { + return { + subtype: 'node', + className: node.object?.node.kind === 'fiber' ? 'Fiber' : 'Context', + description: node.description, + } +} diff --git a/packages/experimental/inspector/src/worker/inspection/cordis-query.ts b/packages/experimental/inspector/src/worker/inspection/cordis-query.ts new file mode 100644 index 0000000000..73794a3815 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/cordis-query.ts @@ -0,0 +1,17 @@ +/** Cordis tree query execution independent of its source carrier. */ + +import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts' +import type { InspectorQuery, InspectorQueryResult } from '../../shared/bridge/messages/query/commands.ts' + +/** + * Execute one closed Inspector query against the shared semantic reader. + * @param reader - Latest committed Cordis tree reader. + * @param query - Validated query command. + * @returns The result corresponding to the query operation. + */ +export async function executeInspectorQuery( + reader: CordisRuntimeTreeReader, + query: InspectorQuery, +): Promise { + return { op: query.op, tree: await reader.getTree() } +} diff --git a/packages/experimental/inspector/src/worker/inspection/cordis-store.ts b/packages/experimental/inspector/src/worker/inspection/cordis-store.ts new file mode 100644 index 0000000000..bb78b1336e --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/cordis-store.ts @@ -0,0 +1,248 @@ +/** Worker-owned repository of CDP-independent Cordis tree snapshots. */ + +import { + parseCordisTreeSnapshot, + type CordisTreeNode, + type CordisTreeSnapshot, +} from '../../shared/cordis/snapshot.ts' +import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts' +import type { InspectorObjectReference } from '../../shared/cordis/object-reference.ts' +import { + projectCordisRuntimeTree, + type CordisInspectionTree as SharedCordisInspectionTree, + type CordisTreeSourceSnapshot as SharedCordisTreeSourceSnapshot, +} from '../../shared/cordis/projector.ts' +import type { CordisRuntimeTree } from '../../shared/cordis/model.ts' +import type { IngestedInspectorRecord, InspectorRecordConsumer } from '../bridge/hub.ts' + +/** Routed Worker snapshot retaining its complete source-generation descriptor. */ +export type CordisTreeSourceSnapshot = SharedCordisTreeSourceSnapshot + +/** Routed Host and Client snapshots retained by the Worker. */ +export type CordisInspectionTree = SharedCordisInspectionTree + +export type { CordisTreeSourceConnection } from '../../shared/cordis/projector.ts' + +/** One object-backed tree node with its owning source generation. */ +export interface CordisTreeObjectRoute extends CordisTreeSourceSnapshot { + readonly node: CordisTreeNode +} + +/** Store mutation consumed by presentation adapters. */ +export type CordisTreeStoreEvent = + | { readonly type: 'snapshot-changed'; readonly source: InspectorSourceDescriptor } + | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor } + +/** Independent bounds for live tree size and retained disconnected snapshots. */ +export interface CordisTreeStoreOptions { + readonly maxNodes: number + readonly maxDisconnectedTrees: number +} + +interface StoredTree extends CordisTreeSourceSnapshot { + readonly nodesByObject: ReadonlyMap +} + +/** Validated latest-value store consumed independently by CDP and future query adapters. */ +export class CordisTreeStore implements InspectorRecordConsumer { + readonly topics = new Set([CORDIS_TREE_TOPIC]) + private readonly trees = new Map() + private readonly disconnected = new Set() + private readonly listeners = new Set<(event: CordisTreeStoreEvent) => void>() + + constructor(private readonly options: CordisTreeStoreOptions) {} + + /** Replace all retained state for one source generation. */ + replace(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void { + const next = this.latest(source, records) + const changed = next === undefined + ? this.remove(source.sourceId) + : this.install(source, next) + if (changed) this.emit({ type: 'snapshot-changed', source }) + } + + /** Apply later state replacements, ignoring unrelated observation topics. */ + append(source: InspectorSourceDescriptor, records: readonly IngestedInspectorRecord[]): void { + const next = this.latest(source, records) + if (next !== undefined && this.install(source, next)) this.emit({ type: 'snapshot-changed', source }) + } + + /** Freeze a closed source generation's last tree and invalidate its object routes. */ + close(source: InspectorSourceDescriptor, reason: string): void { + const current = this.trees.get(source.sourceId) + if (current?.source.generation !== source.generation || current.connection.state === 'disconnected') return + this.trees.set(source.sourceId, { + ...current, + connection: { state: 'disconnected', reason }, + }) + this.disconnected.delete(source.sourceId) + this.disconnected.add(source.sourceId) + while (this.disconnected.size > this.options.maxDisconnectedTrees) { + const oldest = this.disconnected.values().next().value + if (oldest === undefined) break + this.remove(oldest) + } + this.emit({ type: 'source-disconnected', source }) + } + + /** + * Read all current realm snapshots without CDP identifiers. + * @returns Snapshots in source admission order. + */ + snapshots(): CordisTreeSourceSnapshot[] { + return [...this.trees.values()].map(({ source, snapshot, connection }) => ({ source, snapshot, connection })) + } + + /** + * Compose the common realm model into Host and Client slots. + * @returns A detached view whose Host and Client entries share one type. + */ + tree(): CordisInspectionTree { + const snapshots = this.snapshots() + return { + host: snapshots.find(tree => tree.source.kind === 'host') ?? null, + clients: snapshots.filter(tree => tree.source.kind === 'client'), + } + } + + /** + * Read a detached semantic tree without object-routing or CDP identifiers. + * @returns The latest retained Host and Client topology. + */ + readTree(): CordisRuntimeTree { + return projectCordisRuntimeTree(this.tree()) + } + + /** + * Resolve a source-local object reference to its semantic tree node. + * @param source - Active source generation. + * @param reference - Realm-local registry and object handle. + * @returns The matching node while its source remains connected. + */ + resolveObject(source: InspectorSourceDescriptor, reference: InspectorObjectReference): CordisTreeObjectRoute | undefined { + const tree = this.trees.get(source.sourceId) + if (tree === undefined + || tree.source.generation !== source.generation + || tree.connection.state === 'disconnected') return undefined + const node = tree.nodesByObject.get(objectKey(reference)) + return node === undefined ? undefined : this.route(tree, node) + } + + /** + * Resolve a source-local object without requiring the source's presentation fields. + * @param sourceId - Logical source identity. + * @param generation - Active source generation. + * @param reference - Realm-local object reference. + * @returns The matching live tree node. + */ + resolveObjectIdentity( + sourceId: InspectorSourceId, + generation: InspectorSourceGeneration, + reference: InspectorObjectReference, + ): CordisTreeObjectRoute | undefined { + const tree = this.trees.get(sourceId) + if (tree === undefined || tree.source.generation !== generation || tree.connection.state === 'disconnected') { + return undefined + } + const node = tree.nodesByObject.get(objectKey(reference)) + return node === undefined ? undefined : this.route(tree, node) + } + + /** + * Resolve a live reference when only its source realm kind is known. + * @param kind - Host or Client ownership inferred by the Runtime adapter. + * @param reference - Realm-local registry and object handle. + * @returns The matching connected node, when present. + */ + resolveObjectInKind(kind: InspectorSourceDescriptor['kind'], reference: InspectorObjectReference): CordisTreeObjectRoute | undefined { + for (const tree of this.trees.values()) { + if (tree.source.kind !== kind || tree.connection.state === 'disconnected') continue + const node = tree.nodesByObject.get(objectKey(reference)) + if (node !== undefined) return this.route(tree, node) + } + return undefined + } + + /** + * Subscribe to accepted tree replacements and source availability changes. + * @param listener - Repository observer. + * @returns A disposer removing the observer. + */ + subscribe(listener: (event: CordisTreeStoreEvent) => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + private latest( + source: InspectorSourceDescriptor, + records: readonly IngestedInspectorRecord[], + ): CordisTreeSnapshot | undefined { + let snapshot: CordisTreeSnapshot | undefined + for (const record of records) { + if (record.topic !== CORDIS_TREE_TOPIC) continue + const candidate = parseCordisTreeSnapshot(record.payload, this.options.maxNodes) + if (snapshot === undefined || candidate.revision > snapshot.revision) snapshot = candidate + } + if (snapshot === undefined) return undefined + const current = this.trees.get(source.sourceId) + if (current?.source.generation === source.generation && current.snapshot.revision >= snapshot.revision) { + return current.snapshot + } + return snapshot + } + + private install(source: InspectorSourceDescriptor, snapshot: CordisTreeSnapshot): boolean { + const current = this.trees.get(source.sourceId) + if (current?.source.generation === source.generation + && current.snapshot === snapshot + && current.connection.state === 'connected') return false + this.disconnected.delete(source.sourceId) + this.trees.set(source.sourceId, { + source, + snapshot, + connection: { state: 'connected' }, + nodesByObject: new Map(treeNodes(snapshot.root).map(node => [objectKey({ + registryId: snapshot.objectRegistryId, + handle: node.objectHandle, + }), node])), + }) + return true + } + + private remove(sourceId: string): boolean { + this.disconnected.delete(sourceId) + return this.trees.delete(sourceId) + } + + private route(tree: StoredTree, node: CordisTreeNode): CordisTreeObjectRoute { + return { source: tree.source, snapshot: tree.snapshot, connection: tree.connection, node } + } + + private emit(event: CordisTreeStoreEvent): void { + for (const listener of [...this.listeners]) { + try { + listener(event) + } catch { + // One query adapter cannot prevent later repository observers from updating. + } + } + } +} + +function objectKey(reference: InspectorObjectReference): string { + return `${reference.registryId}\0${reference.handle}` +} + +function treeNodes(root: CordisTreeNode): CordisTreeNode[] { + const nodes: CordisTreeNode[] = [] + const pending: CordisTreeNode[] = [root] + while (pending.length > 0) { + const node = pending.pop() + if (node === undefined) break + nodes.push(node) + pending.push(...node.children.toReversed()) + } + return nodes +} diff --git a/packages/experimental/inspector/src/worker/inspection/query-router.ts b/packages/experimental/inspector/src/worker/inspection/query-router.ts new file mode 100644 index 0000000000..ef58e9e122 --- /dev/null +++ b/packages/experimental/inspector/src/worker/inspection/query-router.ts @@ -0,0 +1,255 @@ +/** Worker-side admission, execution, and bounded settlement of non-CDP queries. */ + +import type { CordisRuntimeTreeReader } from '../../shared/cordis/reader.ts' +import type { InspectorSourceGeneration, InspectorSourceId } from '../../shared/bridge/ids.ts' +import { jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts' +import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' +import type { InspectorQueryError } from '../../shared/bridge/messages/query/commands.ts' +import { + isInspectorQueryRequestEnvelope, + parseInspectorQueryFrameIdentity, + parseInspectorQueryRequestFrame, +} from '../../shared/bridge/messages/query/codec.ts' +import type { + InspectorQueryRequestFrame, + InspectorQueryRequestId, + InspectorQueryResponseFrame, +} from '../../shared/bridge/messages/query/frames.ts' +import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' +import { executeInspectorQuery } from './cordis-query.ts' + +/** Carrier operations owned by one Worker query peer. */ +export interface InspectorQueryPeerTransport { + /** Send one bounded Worker response. */ + send(frame: InspectorQueryResponseFrame): void + /** Reject a malformed peer whose request cannot be correlated safely. */ + close(code: number, reason: string): void +} + +interface AcceptedGeneration { + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration +} + +/** Creates isolated query peers over one shared semantic reader. */ +export class InspectorQueryRouter { + private readonly peers = new Set() + private readonly activeBySource = new Map() + + constructor( + private readonly reader: CordisRuntimeTreeReader, + private readonly maxFrameBytes: number, + ) {} + + /** + * Create query state for one Host MessagePort or Client WebSocket. + * @param transport - Carrier response and rejection operations. + * @returns The peer that receives frames from this carrier only. + */ + open(transport: InspectorQueryPeerTransport): InspectorQueryPeer { + const peer: InspectorQueryPeer = new InspectorQueryPeer( + this.reader, + this.maxFrameBytes, + transport, + (accepted) => { + for (const [sourceId, active] of this.activeBySource) { + if (active.peer === peer) this.activeBySource.delete(sourceId) + } + this.activeBySource.set(accepted.sourceId, { ...accepted, peer }) + }, + (accepted): boolean => this.activeBySource.get(accepted.sourceId)?.peer === peer + && this.activeBySource.get(accepted.sourceId)?.generation === accepted.generation, + () => { + this.peers.delete(peer) + for (const [sourceId, active] of this.activeBySource) { + if (active.peer === peer) this.activeBySource.delete(sourceId) + } + }, + ) + this.peers.add(peer) + return peer + } + + /** + * Revoke query access when the source registry closes one generation. + * @param source - Closed source generation. + */ + disconnect(source: InspectorSourceDescriptor): void { + const active = this.activeBySource.get(source.sourceId) + if (active?.generation !== source.generation) return + this.activeBySource.delete(source.sourceId) + active.peer.revoke(source.sourceId, source.generation) + } + + /** Revoke every peer during Worker shutdown. */ + close(): void { + for (const peer of [...this.peers]) peer.close() + this.activeBySource.clear() + } +} + +/** Query protocol state associated with exactly one source carrier. */ +export class InspectorQueryPeer { + private accepted: AcceptedGeneration | undefined + private readonly inFlight = new Map() + private closed = false + + constructor( + private readonly reader: CordisRuntimeTreeReader, + private readonly maxFrameBytes: number, + private readonly transport: InspectorQueryPeerTransport, + private readonly register: (accepted: AcceptedGeneration) => void, + private readonly isRegistered: (accepted: AcceptedGeneration) => boolean, + private readonly unregister: () => void, + ) {} + + /** + * Admit the source generation after the source registry accepts it. + * @param sourceId - Stable source identity. + * @param generation - Active carrier generation. + */ + accept(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): void { + if (this.closed) return + this.accepted = { sourceId, generation } + this.inFlight.clear() + this.register(this.accepted) + } + + /** + * Revoke one generation while leaving its carrier available for a later source/open. + * @param sourceId - Stable source identity. + * @param generation - Generation being removed by the source registry. + */ + revoke(sourceId: InspectorSourceId, generation: InspectorSourceGeneration): void { + if (this.accepted?.sourceId !== sourceId || this.accepted.generation !== generation) return + this.accepted = undefined + this.inFlight.clear() + } + + /** + * Consume a decoded carrier value when it belongs to the query protocol. + * @param value - Untrusted source-to-Worker value. + * @returns Whether this peer owned the value. + */ + receive(value: unknown): boolean { + if (!isInspectorQueryRequestEnvelope(value)) return false + let frame: InspectorQueryRequestFrame + try { + frame = parseInspectorQueryRequestFrame(value) + if (jsonByteLength(frame as unknown as InspectorJsonValue) > this.maxFrameBytes) { + throw new Error(`inspector protocol: query request exceeds ${String(this.maxFrameBytes)} bytes`) + } + } catch (error) { + this.rejectMalformed(value, renderError(error)) + return true + } + const accepted = this.accepted + if (this.closed || accepted === undefined || !this.isRegistered(accepted) + || accepted.sourceId !== frame.sourceId + || accepted.generation !== frame.generation) { + this.sendFailure(frame, 'stale-source', 'Inspector query does not belong to the accepted source generation') + return true + } + if (this.inFlight.has(frame.requestId)) { + this.sendFailure(frame, 'invalid-request', 'Inspector query requestId is already in flight') + return true + } + this.inFlight.set(frame.requestId, accepted) + void this.execute(frame, accepted) + return true + } + + /** Stop this peer and suppress completion from in-flight readers. */ + close(): void { + if (this.closed) return + this.closed = true + this.accepted = undefined + this.inFlight.clear() + this.unregister() + } + + private async execute(frame: InspectorQueryRequestFrame, accepted: AcceptedGeneration): Promise { + try { + const result = await executeInspectorQuery(this.reader, frame.query) + if (!this.canReply(frame, accepted)) return + const response: InspectorQueryResponseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/response', + sourceId: frame.sourceId, + generation: frame.generation, + requestId: frame.requestId, + outcome: { ok: true, result }, + } + if (jsonByteLength(response as unknown as InspectorJsonValue) > this.maxFrameBytes) { + this.sendFailure(frame, 'result-too-large', `Inspector query result exceeds ${String(this.maxFrameBytes)} bytes`) + return + } + this.deliver(response) + } catch (error) { + if (this.canReply(frame, accepted)) this.sendFailure(frame, 'internal-error', renderError(error).message) + } finally { + if (this.inFlight.get(frame.requestId) === accepted) this.inFlight.delete(frame.requestId) + } + } + + private rejectMalformed(value: unknown, error: Error): void { + try { + const identity = parseInspectorQueryFrameIdentity(value) + this.sendFailure(identity, 'invalid-request', error.message) + } catch { + this.rejectTransport(1008, error.message) + } + } + + private sendFailure( + frame: Pick, + code: InspectorQueryError['code'], + message: string, + ): void { + if (this.closed) return + const response: InspectorQueryResponseFrame = { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'query/response', + sourceId: frame.sourceId, + generation: frame.generation, + requestId: frame.requestId, + outcome: { ok: false, error: { code, message } }, + } + if (jsonByteLength(response as unknown as InspectorJsonValue) > this.maxFrameBytes) { + this.rejectTransport(1009, 'Inspector query error exceeds the frame limit') + return + } + this.deliver(response) + } + + private canReply(frame: InspectorQueryRequestFrame, accepted: AcceptedGeneration): boolean { + return !this.closed + && this.accepted === accepted + && this.isRegistered(accepted) + && this.inFlight.get(frame.requestId) === accepted + } + + private deliver(frame: InspectorQueryResponseFrame): void { + try { + this.transport.send(frame) + } catch (error) { + this.rejectTransport(1011, renderError(error).message) + } + } + + private rejectTransport(code: number, reason: string): void { + this.close() + try { + this.transport.close(code, reason.slice(0, 123)) + } catch { + // The carrier is already unusable; query state has reached quiescence. + } + } +} + +function renderError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)) +} diff --git a/packages/experimental/inspector/tests/cordis-query.host.spec.ts b/packages/experimental/inspector/tests/cordis-query.host.spec.ts new file mode 100644 index 0000000000..be258cff62 --- /dev/null +++ b/packages/experimental/inspector/tests/cordis-query.host.spec.ts @@ -0,0 +1,349 @@ +/** Host-driven Cordis query integration. */ + +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createCordisRuntimeTreeReader } from '../src/shared/cordis/reader.ts' +import { + cordisRuntimeSourceId, + type CordisRuntimeContext, + type CordisRuntimeTree, +} from '../src/shared/cordis/model.ts' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import type { InspectorJsonValue } from '../src/shared/json.ts' +import { InspectorQueryConnection } from '../src/shared/bridge/rpc.ts' +import { parseInspectorQueryRequestFrame, parseInspectorQueryResponseFrame } from '../src/shared/bridge/messages/query/codec.ts' +import type { InspectorQueryRequestFrame, InspectorQueryResponseFrame } from '../src/shared/bridge/messages/query/frames.ts' +import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' +import { createInspectorService } from '../src/shared/service.ts' +import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' +import { InspectorQueryRouter } from '../src/worker/inspection/query-router.ts' +import { InspectorClientFixture } from './fixtures/client-source.host.ts' + +describe('consumer-neutral Cordis tree', () => { + it('projects a detached recursive tree without routing identifiers', () => { + const store = new CordisTreeStore({ maxNodes: 10, maxDisconnectedTrees: 1 }) + const source = sourceDescriptor('host-1', 'generation-1', 'host') + store.replace(source, [{ + sequence: 1, + monotonicMs: 1, + topic: 'cordis/tree', + payload: asJson({ + schemaVersion: 0, + revision: 3, + objectRegistryId: 'registry-1', + truncated: false, + root: { + kind: 'context', + objectHandle: 'context-1', + children: [{ + kind: 'fiber', + uid: 12, + objectHandle: 'fiber-1', + children: [{ kind: 'context', objectHandle: 'context-2', children: [] }], + }], + }, + }), + }]) + + const tree = store.readTree() + expect(tree).toEqual({ + schemaVersion: 0, + host: { + source: { sourceId: 'host-1', kind: 'host', label: 'host-1' }, + connection: { state: 'connected' }, + revision: 3, + truncated: false, + root: { + kind: 'context', + children: [{ kind: 'fiber', uid: 12, children: [{ kind: 'context', children: [] }] }], + }, + }, + clients: [], + }) + expect(tree.host?.root).not.toBe(store.tree().host?.snapshot.root) + expect(forbiddenKeys(tree)).toEqual([]) + + store.close(source, 'transport closed') + expect(store.readTree().host?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' }) + + const reconnected = sourceDescriptor('host-1', 'generation-2', 'host') + store.replace(reconnected, [{ + sequence: 1, + monotonicMs: 2, + topic: 'cordis/tree', + payload: asJson({ + schemaVersion: 0, + revision: 4, + objectRegistryId: 'registry-2', + truncated: false, + root: { kind: 'context', objectHandle: 'context-3', children: [] }, + }), + }]) + expect(store.readTree().host).toMatchObject({ + connection: { state: 'connected' }, + revision: 4, + root: { kind: 'context', children: [] }, + }) + expect(forbiddenKeys(store.readTree())).toEqual([]) + }) +}) + +describe('Inspector query protocol', () => { + afterEach(() => { vi.useRealTimers() }) + + it('uses exact request and response codecs', () => { + const hiddenTree = runtimeTree() + if (hiddenTree.host === null) throw new Error('test tree requires a Host realm') + expect(parseInspectorQueryRequestFrame({ + v: 0, + t: 'query/request', + sourceId: 'host-1', + generation: 'generation-1', + requestId: 'query-1', + query: { op: 'cordis-tree/get' }, + })).toMatchObject({ query: { op: 'cordis-tree/get' } }) + expect(() => parseInspectorQueryRequestFrame({ + v: 0, + t: 'query/request', + sourceId: 'host-1', + generation: 'generation-1', + requestId: 'query-1', + query: { op: 'cordis-tree/get', extension: true }, + })).toThrow('unknown field') + expect(() => parseInspectorQueryResponseFrame({ + ...successResponse('query-1', runtimeTree()), + outcome: { + ok: true, + result: { + op: 'cordis-tree/get', + tree: { + ...hiddenTree, + host: { + ...hiddenTree.host, + root: { kind: 'context', objectHandle: 'private', children: [] }, + }, + }, + }, + }, + })).toThrow('unknown field') + }) + + it('correlates results and clears stale, malformed, timed-out, and closed requests', async () => { + const sent: InspectorQueryRequestFrame[] = [] + const connection = new InspectorQueryConnection({ timeoutMs: 20, maxFrameBytes: 16_384 }) + connection.connect(sourceId('host-1'), generation('generation-1'), { + send: (frame) => { sent.push(frame) }, + }) + + const first = connection.request({ op: 'cordis-tree/get' }) + const firstFrame = sent.at(-1)! + expect(connection.receive(successResponse(firstFrame.requestId, runtimeTree()))).toBe(true) + await expect(first).resolves.toEqual({ op: 'cordis-tree/get', tree: runtimeTree() }) + + const stale = connection.request({ op: 'cordis-tree/get' }) + const staleFrame = sent.at(-1)! + expect(connection.receive({ + ...successResponse(staleFrame.requestId, runtimeTree()), + generation: generation('generation-old'), + })).toBe(true) + await expect(stale).rejects.toThrow('source generation does not match') + + const malformed = connection.request({ op: 'cordis-tree/get' }) + const malformedFrame = sent.at(-1)! + const malformedRejection = expect(malformed).rejects.toThrow('Invalid Inspector query response') + expect(() => connection.receive({ + ...successResponse(malformedFrame.requestId, runtimeTree()), + extension: true, + })).toThrow('unknown field') + await malformedRejection + + connection.connect(sourceId('host-1'), generation('generation-2'), { + send: (frame) => { sent.push(frame) }, + }) + vi.useFakeTimers() + const timedOut = connection.request({ op: 'cordis-tree/get' }) + const timeoutRejection = expect(timedOut).rejects.toThrow('timed out') + await vi.advanceTimersByTimeAsync(21) + await timeoutRejection + vi.useRealTimers() + + const closed = connection.request({ op: 'cordis-tree/get' }) + connection.close() + await expect(closed).rejects.toThrow('closed') + }) + + it('rejects malformed, stale, and oversized Worker requests with bounded outcomes', async () => { + const responses: InspectorQueryResponseFrame[] = [] + const close = vi.fn() + const largeTree = runtimeTree({ + kind: 'context', + children: Array.from({ length: 100 }, () => ({ kind: 'context', children: [] } as const)), + }) + const router = new InspectorQueryRouter(createCordisRuntimeTreeReader(() => largeTree), 512) + const peer = router.open({ send: (frame) => { responses.push(frame) }, close }) + peer.accept(sourceId('host-1'), generation('generation-1')) + + expect(peer.receive(requestFrame('query-stale', 'generation-old'))).toBe(true) + expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'stale-source' } }) + + expect(peer.receive({ ...requestFrame('query-malformed'), extension: true })).toBe(true) + expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'invalid-request' } }) + + expect(peer.receive(requestFrame('query-large'))).toBe(true) + await vi.waitFor(() => { + expect(responses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'result-too-large' } }) + }) + expect(close).not.toHaveBeenCalled() + + const requester = new InspectorQueryConnection({ timeoutMs: 100, maxFrameBytes: 512 }) + const pairedPeer = router.open({ + send: (frame) => { requester.receive(frame) }, + close: vi.fn(), + }) + pairedPeer.accept(sourceId('client-2'), generation('generation-1')) + requester.connect(sourceId('client-2'), generation('generation-1'), { + send: (frame) => { pairedPeer.receive(frame) }, + }) + await expect(requester.request({ op: 'cordis-tree/get' })).rejects.toMatchObject({ code: 'result-too-large' }) + requester.close() + }) + + it('revokes an older carrier when the same source opens a new generation', () => { + const firstResponses: InspectorQueryResponseFrame[] = [] + const router = new InspectorQueryRouter(createCordisRuntimeTreeReader(() => runtimeTree()), 16_384) + const first = router.open({ send: (frame) => { firstResponses.push(frame) }, close: vi.fn() }) + const second = router.open({ send: vi.fn(), close: vi.fn() }) + first.accept(sourceId('client-1'), generation('generation-1')) + second.accept(sourceId('client-1'), generation('generation-2')) + + expect(first.receive({ + ...requestFrame('query-old', 'generation-1'), + sourceId: sourceId('client-1'), + })).toBe(true) + expect(firstResponses.at(-1)?.outcome).toMatchObject({ ok: false, error: { code: 'stale-source' } }) + }) +}) + +describe('Cordis query service integration', () => { + let inspector: InspectorHandle | undefined + let clientSource: InspectorClientFixture | undefined + const observers: Array<() => void> = [] + + afterEach(async () => { + for (const dispose of observers.splice(0).reverse()) dispose() + await clientSource?.close() + clientSource = undefined + await inspector?.close() + inspector = undefined + }) + + it('returns the same Worker snapshot to Host and Client services without a CDP connection', async () => { + inspector = await startInspector({ + port: 0, + captureFetch: false, + queryTimeoutMs: 1_000, + maxCordisNodes: 100, + }) + const hostContext = new Context() + observers.push(publishHostCordisTree(hostContext, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 })) + const hostService = createInspectorService(inspector.source) + + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Query Client' }) + + await vi.waitFor(async () => { + const [hostTree, clientTree] = await Promise.all([ + hostService.cordis.getTree(), + clientSource!.getCordisTree(), + ]) + expect(hostTree).toEqual(clientTree) + expect(hostTree.host?.source.kind).toBe('host') + expect(hostTree.clients).toHaveLength(1) + expect(forbiddenKeys(hostTree)).toEqual([]) + }) + + await clientSource.close() + clientSource = undefined + await vi.waitFor(async () => { + const tree = await hostService.cordis.getTree() + expect(tree.clients[0]?.connection.state).toBe('disconnected') + }) + }) +}) + +function sourceDescriptor( + id: string, + sourceGeneration: string, + kind: InspectorSourceDescriptor['kind'], +): InspectorSourceDescriptor { + return { + sourceId: sourceId(id), + generation: generation(sourceGeneration), + kind, + label: id, + timeOriginMs: 0, + capabilities: [], + } +} + +function sourceId(value: string): InspectorSourceDescriptor['sourceId'] { + return inspectorId<'InspectorSourceId'>(value, 'sourceId') +} + +function generation(value: string): InspectorSourceDescriptor['generation'] { + return inspectorId<'InspectorSourceGeneration'>(value, 'generation') +} + +function runtimeTree(root: CordisRuntimeContext = { kind: 'context', children: [] }): CordisRuntimeTree { + return { + schemaVersion: 0, + host: { + source: { sourceId: cordisRuntimeSourceId('host-1'), kind: 'host', label: 'Host' }, + connection: { state: 'connected' }, + revision: 1, + truncated: false, + root, + }, + clients: [], + } +} + +function requestFrame(requestId: string, sourceGeneration = 'generation-1'): InspectorQueryRequestFrame { + return { + v: 0, + t: 'query/request', + sourceId: sourceId('host-1'), + generation: generation(sourceGeneration), + requestId: inspectorId<'InspectorQueryRequestId'>(requestId, 'requestId'), + query: { op: 'cordis-tree/get' }, + } +} + +function successResponse(requestId: string, tree: CordisRuntimeTree): InspectorQueryResponseFrame { + return { + v: 0, + t: 'query/response', + sourceId: sourceId('host-1'), + generation: generation('generation-1'), + requestId: inspectorId<'InspectorQueryRequestId'>(requestId, 'requestId'), + outcome: { ok: true, result: { op: 'cordis-tree/get', tree } }, + } +} + +function forbiddenKeys(value: unknown): string[] { + if (value === null || typeof value !== 'object') return [] + if (Array.isArray(value)) return value.flatMap(forbiddenKeys) + const forbidden = new Set([ + 'objectHandle', 'objectRegistryId', 'registryId', 'generation', 'executionContextId', + 'scriptId', 'nodeId', 'backendNodeId', 'objectId', 'remoteObjectId', + ]) + return Reflect.ownKeys(value).flatMap((key) => { + if (typeof key !== 'string') return [] + return [...(forbidden.has(key) ? [key] : []), ...forbiddenKeys(Reflect.get(value, key))] + }) +} + +function asJson(value: object): InspectorJsonValue { + return value as unknown as InspectorJsonValue +} diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts new file mode 100644 index 0000000000..1f014d4311 --- /dev/null +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -0,0 +1,454 @@ +/** Host-driven Cordis tree integration. */ + +import { Context } from '@deepseek-ai/cordis' +import WebSocket, { type RawData } from 'ws' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CordisTreeCollector } from '../src/shared/cordis/collector.ts' +import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' +import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' +import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts' +import { inspectorId } from '../src/shared/bridge/ids.ts' +import type { InspectorJsonValue } from '../src/shared/json.ts' +import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' +import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' +import { InspectorClientFixture } from './fixtures/client-source.host.ts' + +interface CdpMessage { + readonly id?: number + readonly method?: string + readonly params?: Record + readonly result?: Record + readonly error?: { message: string } +} + +interface CdpNode { + readonly nodeId: number + readonly backendNodeId: number + readonly localName: string + readonly attributes?: string[] + readonly children?: CdpNode[] +} + +class CdpClient { + private nextId = 0 + private readonly pending = new Map void>() + readonly events: CdpMessage[] = [] + + private constructor(private readonly socket: WebSocket) { + socket.on('message', (data) => { + const message = JSON.parse(rawText(data)) as CdpMessage + if (message.id !== undefined) this.pending.get(message.id)?.(message) + else this.events.push(message) + }) + } + + static async connect(url: string): Promise { + const socket = new WebSocket(url) + await new Promise((resolve, reject) => { + socket.once('open', () => { resolve() }) + socket.once('error', reject) + }) + return new CdpClient(socket) + } + + call(method: string, params: Record = {}): Promise { + const id = ++this.nextId + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { reject(new Error(`CDP call timed out: ${method}`)) }, 5_000) + this.pending.set(id, (message) => { + clearTimeout(timer) + this.pending.delete(id) + resolve(message) + }) + this.socket.send(JSON.stringify({ id, method, params })) + }) + } + + async close(): Promise { + if (this.socket.readyState === WebSocket.CLOSED) return + const closed = new Promise((resolve) => { this.socket.once('close', () => { resolve() }) }) + this.socket.close() + await closed + } +} + +describe('Cordis tree inspection', () => { + let inspector: InspectorHandle | undefined + let cdp: CdpClient | undefined + let secondCdp: CdpClient | undefined + let clientSource: InspectorClientFixture | undefined + const observers: Array<() => void> = [] + const fibers: Array<{ dispose(): Promise }> = [] + + afterEach(async () => { + for (const dispose of observers.splice(0).reverse()) dispose() + for (const fiber of fibers.splice(0).reverse()) await fiber.dispose() + await clientSource?.close() + clientSource = undefined + await cdp?.close() + cdp = undefined + await secondCdp?.close() + secondCdp = undefined + await inspector?.close() + inspector = undefined + Reflect.deleteProperty(globalThis, '__cordisHostProbe') + }) + + it('preserves separate Fiber and Context identities in one shared snapshot model', async () => { + const root = new Context() + const parent = root.isolate('probe') + const fiber = parent.plugin({ name: 'child', apply() {} }) + await fiber.await() + const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) + + const snapshot = collector.snapshot() + expect(parseCordisTreeSnapshot(snapshot, 100)).toEqual(snapshot) + const nodes = treeNodes(snapshot.root) + const fiberNode = nodes.find(node => node.kind === 'fiber' && node.uid === fiber.uid) + if (fiberNode === undefined) throw new Error('expected child Fiber node') + expect(nodes.every(node => !('id' in node) && !('parentId' in node))).toBe(true) + expect(() => parseCordisTreeSnapshot({ + ...snapshot, + root: { ...snapshot.root, children: [{ ...fiberNode, children: [] }] }, + }, 100)).toThrow('exactly one Context') + const contextNode = fiberNode.children[0] + const isolateNode = nodes.find(node => node.kind === 'context' + && collector.objects.resolve(node.objectHandle) === parent) + + expect(snapshot.root.kind).toBe('context') + expect(nodes.some(node => node.kind === 'fiber' && node.uid === 0)).toBe(false) + expect(isolateNode?.children).toContain(fiberNode) + const retainedFiber = collector.objects.resolve(fiberNode.objectHandle) + expect(Reflect.get(retainedFiber ?? {}, 'uid')).toBe(fiber.uid) + expect(Reflect.get(retainedFiber ?? {}, 'ctx') === fiber.ctx).toBe(true) + expect(collector.objects.resolve(contextNode.objectHandle) === fiber.ctx).toBe(true) + const identifiedFiber = collector.objects.identify(fiber) + expect(identifiedFiber).toEqual({ + registryId: snapshot.objectRegistryId, + handle: fiberNode.objectHandle, + }) + expect(collector.objects.identify(Object.create(parent) as object)).toBeUndefined() + + collector.close() + await fiber.dispose() + }) + + it('freezes a disconnected snapshot and replaces it with the reconnect generation', () => { + const root = new Context() + const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) + const snapshot = collector.snapshot() + const store = new CordisTreeStore({ maxNodes: 100, maxDisconnectedTrees: 1 }) + const first = source('client-a', 'generation-1') + store.replace(first, [{ sequence: 1, monotonicMs: 1, topic: 'cordis/tree', payload: asJson(snapshot) }]) + + const object = snapshot.root + expect(store.resolveObject(first, { + registryId: snapshot.objectRegistryId, + handle: object.objectHandle, + })).toBeDefined() + store.close(first, 'transport closed') + expect(store.snapshots()[0]?.connection).toEqual({ state: 'disconnected', reason: 'transport closed' }) + expect(store.resolveObject(first, { + registryId: snapshot.objectRegistryId, + handle: object.objectHandle, + })).toBeUndefined() + + const reconnected = source('client-a', 'generation-2') + store.replace(reconnected, [{ + sequence: 1, + monotonicMs: 2, + topic: 'cordis/tree', + payload: asJson({ ...snapshot, revision: snapshot.revision + 1 }), + }]) + expect(store.snapshots()).toEqual([ + expect.objectContaining({ source: reconnected, connection: { state: 'connected' } }), + ]) + + store.close(reconnected, 'transport closed again') + const other = source('client-b', 'generation-1') + store.replace(other, [{ sequence: 1, monotonicMs: 3, topic: 'cordis/tree', payload: asJson(snapshot) }]) + store.close(other, 'other transport closed') + const retained = store.snapshots() + expect(retained).toHaveLength(1) + expect(retained[0]?.source).toEqual(other) + expect(retained[0]?.connection.state).toBe('disconnected') + collector.close() + }) + + it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + const host = new Context() + const hostFiber = host.plugin({ name: 'host-child', apply() {} }) + fibers.push(hostFiber) + await hostFiber.await() + Reflect.set(globalThis, '__cordisHostProbe', host) + observers.push(publishHostCordisTree(host, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 })) + + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Tree Client' }) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + + let document: CdpNode | undefined + await vi.waitFor(async () => { + const response = await cdp!.call('DOM.getDocument') + expect(response.error).toBeUndefined() + document = response.result?.root as CdpNode + expect(hostContainer(document)).toBeDefined() + expect(clientContainers(document)).toHaveLength(1) + }) + if (document === undefined) throw new Error('DOM.getDocument returned no root') + expect(document.children?.map(node => node.localName)).toEqual(['host', 'clients']) + expect(document.children?.every(node => (node.attributes ?? []).length === 0)).toBe(true) + + const stored = await cdp.call('DSHInspector.getCordisTree') + const model = stored.result?.tree as { + host: { root: Record } | null + clients: Array<{ root: Record }> + } + expect(model.host?.root).toMatchObject({ kind: 'context' }) + expect(model.clients).toHaveLength(1) + expect(model.clients[0]?.root).toMatchObject({ kind: 'context' }) + expect(model.host?.root).not.toHaveProperty('nodeId') + expect(model.host?.root).not.toHaveProperty('backendNodeId') + + const realms = [ + ['host', hostContainer(document)], + ['client', clientContainers(document)[0]], + ] as const + for (const [realmKind, realm] of realms) { + expect(realm?.attributes ?? []).toEqual([]) + const rootContext = realm?.children?.[0] + expect(rootContext?.localName).toBe('context') + expect(rootContext?.children?.[0]?.localName).toBe('fiber') + expect(rootContext?.children?.[0]?.children?.[0]?.localName).toBe('context') + for (const entityKind of ['context', 'fiber']) { + const node = realm === undefined ? undefined : walk(realm).find(item => item.localName === entityKind) + if (node === undefined) throw new Error(`missing ${realmKind} ${entityKind} node`) + expect(node.attributes ?? []).toEqual(entityKind === 'fiber' + ? ['uid', expect.stringMatching(/^\d+$/u)] + : []) + expect(node.nodeId).toBeGreaterThan(0) + expect(node.backendNodeId).toBeGreaterThan(0) + const objectGroup = `tree-${realmKind}-${entityKind}` + const resolved = await cdp.call('DOM.resolveNode', { nodeId: node.nodeId, objectGroup }) + expect(resolved.error).toBeUndefined() + const remote = resolved.result?.object as Record + expect(remote).toMatchObject({ + type: 'object', + subtype: 'node', + className: entityKind === 'fiber' ? 'Fiber' : 'Context', + }) + expect(typeof remote.objectId).toBe('string') + const properties = await cdp.call('Runtime.getProperties', { objectId: remote.objectId, ownProperties: true }) + expect(properties.error).toBeUndefined() + await expect(cdp.call('DOM.requestNode', { objectId: remote.objectId })).resolves.toMatchObject({ + result: { nodeId: node.nodeId }, + }) + await cdp.call('Runtime.releaseObjectGroup', { objectGroup }) + } + } + + const hostNode = walk(hostContainer(document)!).find(item => item.localName === 'context')! + const hostEvaluated = await cdp.call('Runtime.evaluate', { expression: 'globalThis.__cordisHostProbe' }) + expect(hostEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { + objectId: (hostEvaluated.result?.result as Record).objectId, + })).resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } }) + const hostThrown = await cdp.call('Runtime.evaluate', { expression: 'throw globalThis.__cordisHostProbe' }) + const hostException = hostThrown.result?.exceptionDetails as Record + const hostExceptionObject = hostException.exception as Record + expect(hostExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { objectId: hostExceptionObject.objectId })) + .resolves.toMatchObject({ result: { nodeId: hostNode.nodeId } }) + + let clientContextId: number | undefined + await vi.waitFor(() => { + const event = cdp!.events.find(item => item.method === 'Runtime.executionContextCreated' + && String((item.params?.context as { name?: string } | undefined)?.name).startsWith('Client')) + clientContextId = (event?.params?.context as { id?: number } | undefined)?.id + expect(clientContextId).toBeTypeOf('number') + }) + const clientNode = walk(clientContainers(document)[0]!).find(item => item.localName === 'context')! + const clientEvaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__cordisClientProbe', + contextId: clientContextId, + }) + expect(clientEvaluated.result?.result).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { + objectId: (clientEvaluated.result?.result as Record).objectId, + })).resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } }) + const clientThrown = await cdp.call('Runtime.evaluate', { + expression: 'throw globalThis.__cordisClientProbe', + contextId: clientContextId, + }) + const clientException = clientThrown.result?.exceptionDetails as Record + const clientExceptionObject = clientException.exception as Record + expect(clientExceptionObject).toMatchObject({ subtype: 'node', className: 'Context' }) + await expect(cdp.call('DOM.requestNode', { objectId: clientExceptionObject.objectId })) + .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } }) + + const consoleOffset = cdp.events.length + await clientSource.logCordis('cordis-client-console') + let consoleObject: Record | undefined + let consoleFiber: Record | undefined + await vi.waitFor(() => { + const event = cdp!.events.slice(consoleOffset).find((candidate) => { + const params = candidate.params + if (params === undefined + || candidate.method !== 'Runtime.consoleAPICalled' + || params.executionContextId !== clientContextId + || !Array.isArray(params.args)) return false + return params.args.some(argument => (argument as { value?: unknown }).value === 'cordis-client-console') + }) + const args = event?.params?.args + consoleObject = Array.isArray(args) ? args[0] as Record | undefined : undefined + consoleFiber = Array.isArray(args) ? args[1] as Record | undefined : undefined + expect(consoleObject).toMatchObject({ type: 'object', subtype: 'node', className: 'Context' }) + expect(consoleFiber).toMatchObject({ type: 'object', subtype: 'node', className: 'Fiber' }) + }) + await expect(cdp.call('DOM.requestNode', { objectId: consoleObject!.objectId })) + .resolves.toMatchObject({ result: { nodeId: clientNode.nodeId } }) + const requestedFiber = await cdp.call('DOM.requestNode', { objectId: consoleFiber!.objectId }) + const requestedFiberId = (requestedFiber.result as { nodeId?: number } | undefined)?.nodeId + const clientFiberNode = walk(clientContainers(document)[0]!).find(node => node.nodeId === requestedFiberId) + expect(clientFiberNode).toMatchObject({ + localName: 'fiber', + attributes: ['uid', String(clientSource.fiberUid)], + }) + + const firstResolved = await cdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId }) + const firstObjectId = (firstResolved.result?.object as Record).objectId + secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode + const secondNode = walk(secondDocument).find(node => node.backendNodeId === clientNode.backendNodeId) + expect(secondNode).toBeDefined() + const secondResolved = await secondCdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId }) + const secondObjectId = (secondResolved.result?.object as Record).objectId + expect(secondObjectId).not.toBe(firstObjectId) + expect((await secondCdp.call('DOM.requestNode', { objectId: firstObjectId })).error).toBeDefined() + + const eventOffset = cdp.events.length + await clientSource.close() + clientSource = undefined + await vi.waitFor(() => { + const events = cdp!.events.slice(eventOffset) + expect(events.some(event => event.method === 'Runtime.executionContextDestroyed' + && event.params?.executionContextId === clientContextId)).toBe(true) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + + const disconnectedDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode + const disconnectedClient = clientContainers(disconnectedDocument)[0] + expect(disconnectedClient).toBeDefined() + expect(walk(disconnectedClient!).find(node => node.backendNodeId === clientNode.backendNodeId)?.nodeId) + .toBe(clientNode.nodeId) + expect((await cdp.call('DOM.resolveNode', { nodeId: clientNode.nodeId })).error?.message) + .toContain('Cordis realm is disconnected') + expect((await cdp.call('DOM.requestNode', { + objectId: (clientEvaluated.result?.result as Record).objectId, + })).error).toBeDefined() + const disconnectedTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ connection: { state: string } }> + } + expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected') + }) + + it('restores a disconnected Client tree from a new transport generation', async () => { + inspector = await startInspector({ + port: 0, + captureFetch: false, + maxCordisNodes: 100, + clientReconnectBaseMs: 10, + clientReconnectMaxMs: 20, + }) + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Reconnect Client' }) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + + let document: CdpNode | undefined + let contextId: number | undefined + await vi.waitFor(async () => { + document = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode + expect(clientContainers(document)).toHaveLength(1) + const created = cdp!.events.find(event => event.method === 'Runtime.executionContextCreated' + && String((event.params?.context as { name?: string } | undefined)?.name).startsWith('Client')) + contextId = (created?.params?.context as { id?: number } | undefined)?.id + expect(contextId).toBeTypeOf('number') + }) + const initialTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ source: { sourceId: string } }> + } + const sourceId = initialTree.clients[0]?.source.sourceId + const eventOffset = cdp.events.length + await clientSource.disconnect() + + await vi.waitFor(() => { + const events = cdp!.events.slice(eventOffset) + const destroyed = events.findIndex(event => event.method === 'Runtime.executionContextDestroyed' + && event.params?.executionContextId === contextId) + const created = events.findIndex((event) => { + if (event.method !== 'Runtime.executionContextCreated') return false + const context = event.params?.context as { id?: number } | undefined + return typeof context?.id === 'number' && context.id !== contextId + }) + const refreshed = events.findIndex(event => event.method === 'DOM.documentUpdated') + expect(destroyed).toBeGreaterThanOrEqual(0) + expect(created).toBeGreaterThan(destroyed) + expect(refreshed).toBeGreaterThan(created) + expect(events.slice(0, created).some(event => event.method?.startsWith('DOM.'))).toBe(false) + }) + + await vi.waitFor(async () => { + const current = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode + expect(clientContainers(current)).toHaveLength(1) + expect(clientContainers(current)[0]?.children?.[0]?.localName).toBe('context') + const tree = (await cdp!.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ + source: { sourceId: string } + connection: { state: string } + }> + } + expect(tree.clients).toHaveLength(1) + expect(tree.clients[0]?.source.sourceId).toBe(sourceId) + expect(tree.clients[0]?.connection.state).toBe('connected') + }) + }) +}) + +function source(sourceId: string, generation: string): InspectorSourceDescriptor { + return { + sourceId: inspectorId<'InspectorSourceId'>(sourceId, 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>(generation, 'generation'), + kind: 'client', + label: sourceId, + timeOriginMs: 0, + capabilities: [], + } +} + +function hostContainer(root: CdpNode | undefined): CdpNode | undefined { + return root?.children?.find(node => node.localName === 'host') +} + +function clientContainers(root: CdpNode | undefined): CdpNode[] { + return root?.children?.find(node => node.localName === 'clients')?.children + ?.filter(node => node.localName === 'client') ?? [] +} + +function walk(root: CdpNode): CdpNode[] { + return [root, ...(root.children ?? []).flatMap(walk)] +} + +function treeNodes(root: CordisTreeNode): CordisTreeNode[] { + return [root, ...root.children.flatMap(treeNodes)] +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +function asJson(value: object): InspectorJsonValue { + return value as unknown as InspectorJsonValue +} diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 99743551e4..00f2992f94 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1015,6 +1015,23 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'inspector', + summary: 'Shared Host/Client service façade over the realm\'s source publisher.', + description: 'Shared Host/Client service façade over the realm\'s source publisher.', + methods: [ + { + signature: 'publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void', + description: 'Publish one JSON observation without waiting for Worker delivery.', + parameters: [{ name: 'topic', description: 'Domain-owned topic name.' }, { name: 'payload', description: 'JSON value validated before it reaches the carrier.' }, { name: 'monotonicMs', description: 'Source-clock timestamp; defaults to `performance.now()`.' }], + }, + { + signature: 'readonly cordis: CordisRuntimeTreeReader', + description: 'Read-only Cordis topology queries independent of CDP sessions.', + parameters: [], + }, + ], + }, { key: 'invariants', summary: 'Package-owned invariant registry with global and regex-based selection.', @@ -3678,6 +3695,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CordisInspectRequestId', declaration: 'export type CordisInspectRequestId = Branded<\'CordisInspectRequestId\'>;', }, + { + name: 'CordisRuntimeConnection', + declaration: 'export type CordisRuntimeConnection = {\n readonly state: \'connected\';\n} | {\n readonly state: \'disconnected\';\n readonly reason: string;\n};', + }, + { + name: 'CordisRuntimeContext', + declaration: 'export interface CordisRuntimeContext {\n readonly kind: \'context\';\n readonly children: readonly CordisRuntimeNode[];\n}', + }, + { + name: 'CordisRuntimeFiber', + declaration: 'export interface CordisRuntimeFiber {\n readonly kind: \'fiber\';\n readonly uid: number;\n readonly children: readonly [\n CordisRuntimeContext\n ];\n}', + }, + { + name: 'CordisRuntimeNode', + declaration: 'export type CordisRuntimeNode = CordisRuntimeContext | CordisRuntimeFiber;', + }, + { + name: 'CordisRuntimeRealm', + declaration: 'export interface CordisRuntimeRealm {\n readonly source: CordisRuntimeSource;\n readonly connection: CordisRuntimeConnection;\n readonly revision: number;\n readonly truncated: boolean;\n readonly root: CordisRuntimeContext;\n}', + }, + { + name: 'CordisRuntimeSource', + declaration: 'export interface CordisRuntimeSource {\n readonly sourceId: CordisRuntimeSourceId;\n readonly kind: CordisRuntimeSourceKind;\n readonly label: string;\n}', + }, + { + name: 'CordisRuntimeSourceId', + declaration: 'export type CordisRuntimeSourceId = InspectorId<\'CordisRuntimeSourceId\'>;', + }, + { + name: 'CordisRuntimeSourceKind', + declaration: 'export type CordisRuntimeSourceKind = \'host\' | \'client\';', + }, + { + name: 'CordisRuntimeTree', + declaration: 'export interface CordisRuntimeTree {\n readonly schemaVersion: typeof CORDIS_RUNTIME_TREE_SCHEMA_VERSION;\n readonly host: CordisRuntimeRealm | null;\n readonly clients: readonly CordisRuntimeRealm[];\n}', + }, + { + name: 'CordisRuntimeTreeReader', + declaration: 'export interface CordisRuntimeTreeReader {\n getTree(): Promise;\n}', + }, { name: 'CreateAgentOptions', declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: \'subagent\';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', @@ -4006,6 +4063,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'IndexInjectionPlacement', declaration: 'export type IndexInjectionPlacement = \'head\' | \'body\';', }, + { + name: 'InspectorId', + declaration: 'export type InspectorId = Branded;', + }, + { + name: 'InspectorJsonObject', + declaration: 'export interface InspectorJsonObject {\n readonly [key: string]: InspectorJsonValue;\n}', + }, + { + name: 'InspectorJsonPrimitive', + declaration: 'export type InspectorJsonPrimitive = null | boolean | number | string;', + }, + { + name: 'InspectorJsonValue', + declaration: 'export type InspectorJsonValue = InspectorJsonPrimitive | readonly InspectorJsonValue[] | InspectorJsonObject;', + }, { name: 'InvariantFailure', declaration: 'export type InvariantFailure = (message: string) => never;', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 2392962be1..d9870dabc7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -79,6 +79,7 @@ export const SERVICE_PAGE: Record = { fileReferences: 'session-reference.md', fs: 'filesystem.md', goals: 'goal.md', + inspector: 'extensions.md', webServer: 'web-server.md', invariants: 'invariants.md', llm: 'llm-streaming.md', @@ -249,6 +250,7 @@ export const LINK_MAP: Readonly> = { GenerateOptions: 'llm-streaming.md', InboxItem: 'core.md', InboxPlacement: 'core.md', + InspectorJsonValue: 'extensions.md', MessageId: 'llm-streaming.md', ResumeAgentOptions: 'core.md', SettleReason: 'core.md', From 6822ad3afc2efbc2b5d1f55e1bf6ef31e35b8f1c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:27:28 +0800 Subject: [PATCH 068/130] test(inspector): enforce execution-plane boundaries --- .../inspector/tests/layout.host.spec.ts | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 packages/experimental/inspector/tests/layout.host.spec.ts diff --git a/packages/experimental/inspector/tests/layout.host.spec.ts b/packages/experimental/inspector/tests/layout.host.spec.ts new file mode 100644 index 0000000000..39b8cf7bf4 --- /dev/null +++ b/packages/experimental/inspector/tests/layout.host.spec.ts @@ -0,0 +1,112 @@ +/** Host-side source layout invariants. */ + +import { readdir, readFile } from 'node:fs/promises' +import { dirname, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)) +const packageRoot = fileURLToPath(new URL('../', import.meta.url)) +const testsRoot = fileURLToPath(new URL('./', import.meta.url)) + +describe('Inspector execution layout', () => { + it('keeps Client and Host implementation paths mirrored', async () => { + expect(await sourceFiles('client')).toEqual(await sourceFiles('host')) + }) + + it('keeps Worker Client and Host backend paths mirrored', async () => { + expect(await sourceFiles('worker/realms/client')).toEqual(await sourceFiles('worker/realms/host')) + }) + + it('keeps shared modules independent of execution-specific directories', async () => { + await expectNoImports('shared', ['client', 'host', 'worker']) + }) + + it('keeps Client and Host modules isolated from each other and the Worker implementation', async () => { + await expectNoImports('client', ['host', 'worker']) + await expectNoImports('host', ['client', 'worker']) + }) + + it('keeps compiler files and specs on their declared execution face', async () => { + const hostFiles = await compilerFiles('tsconfig.host.json') + const clientFiles = await compilerFiles('tsconfig.client.json') + expect(hostFiles.some(file => file.startsWith('src/client/'))).toBe(false) + expect(clientFiles.some(file => file.startsWith('src/host/') || file.startsWith('src/worker/'))).toBe(false) + + const testFiles = (await walk(testsRoot)).filter(file => file.endsWith('.ts')) + const specs = testFiles.filter(file => file.endsWith('.spec.ts')) + expect(specs.every(file => file.endsWith('.host.spec.ts') || file.endsWith('.client.spec.ts'))).toBe(true) + await expectTestImports(testFiles.filter(file => + file.endsWith('.host.ts') || file.endsWith('.host.spec.ts')), ['client']) + await expectTestImports(testFiles.filter(file => + file.endsWith('.client.ts') || file.endsWith('.client.spec.ts')), ['host', 'worker']) + }) + + it('keeps Worker repositories and realm backends independent of the Chrome adapter', async () => { + await expectNoImports('worker/inspection', ['worker/cdp']) + await expectNoImports('worker/realms', ['worker/cdp']) + }) +}) + +async function sourceFiles(directory: string): Promise { + const root = resolve(sourceRoot, directory) + return (await walk(root)) + .filter(file => file.endsWith('.ts')) + .map(file => relative(root, file).split(sep).join('/')) + .sort() +} + +async function compilerFiles(config: string): Promise { + const parsed = JSON.parse(await readFile(resolve(packageRoot, config), 'utf8')) as { files?: unknown } + if (!Array.isArray(parsed.files) || !parsed.files.every(file => typeof file === 'string')) { + throw new Error(`${config} must declare a string files array`) + } + return parsed.files +} + +async function expectTestImports(files: readonly string[], forbidden: readonly string[]): Promise { + for (const file of files) { + const source = await readFile(file, 'utf8') + for (const specifier of relativeSpecifiers(source)) { + const target = resolve(dirname(file), specifier) + for (const directory of forbidden) { + const forbiddenRoot = resolve(sourceRoot, directory) + expect( + target === forbiddenRoot || target.startsWith(`${forbiddenRoot}${sep}`), + `${relative(testsRoot, file)} imports ${specifier}`, + ).toBe(false) + } + } + } +} + +async function expectNoImports(owner: string, forbidden: readonly string[]): Promise { + const root = resolve(sourceRoot, owner) + for (const file of await walk(root)) { + if (!file.endsWith('.ts')) continue + const source = await readFile(file, 'utf8') + for (const specifier of relativeSpecifiers(source)) { + const target = resolve(dirname(file), specifier) + for (const directory of forbidden) { + const forbiddenRoot = resolve(sourceRoot, directory) + expect( + target === forbiddenRoot || target.startsWith(`${forbiddenRoot}${sep}`), + `${relative(sourceRoot, file)} imports ${specifier}`, + ).toBe(false) + } + } + } +} + +async function walk(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }) + const files = await Promise.all(entries.map(async (entry) => { + const value = resolve(directory, entry.name) + return entry.isDirectory() ? await walk(value) : [value] + })) + return files.flat() +} + +function relativeSpecifiers(source: string): string[] { + return [...source.matchAll(/(?:from\s+|import\s*\()['"](\.[^'"]+)['"]/gu)].map(match => match[1] ?? '') +} From 008ae0c01e9bbf0dfc8ea4711d8833da6337ae68 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 01:28:14 +0800 Subject: [PATCH 069/130] docs(inspector): record cross-realm architecture --- ...-08-23-cross-realm-cdp-inspector.i18n.yaml | 6 + .../2026-08-23-cross-realm-cdp-inspector.md | 100 ++++++++++++++++ ...2026-08-23-cross-realm-cdp-inspector.zh.md | 100 ++++++++++++++++ ...4-cordis-runtime-tree-inspection.i18n.yaml | 6 + ...26-08-24-cordis-runtime-tree-inspection.md | 113 ++++++++++++++++++ ...08-24-cordis-runtime-tree-inspection.zh.md | 113 ++++++++++++++++++ ...ution-realms-and-protocol-planes.i18n.yaml | 6 + ...or-execution-realms-and-protocol-planes.md | 104 ++++++++++++++++ ...execution-realms-and-protocol-planes.zh.md | 104 ++++++++++++++++ docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 + docs/capability-seams.zh.md | 4 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 68 +++++++++++ docs/config-catalog.zh.md | 68 +++++++++++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 6 +- docs/event-producer-consumer.zh.md | 6 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 48 +++----- docs/module-graph.zh.md | 50 ++++---- docs/subsystems/extensions.i18n.yaml | 4 +- docs/subsystems/extensions.md | 18 +++ docs/subsystems/extensions.zh.md | 18 +++ packages/experimental/README.i18n.yaml | 4 +- packages/experimental/README.md | 3 +- packages/experimental/README.zh.md | 3 +- .../experimental/inspector/README.i18n.yaml | 6 + packages/experimental/inspector/README.md | 105 ++++++++++++++++ packages/experimental/inspector/README.zh.md | 105 ++++++++++++++++ scripts/gen-doc-graphs.ts | 7 ++ .../verify-package-readme-model-experience.ts | 1 + 32 files changed, 1117 insertions(+), 79 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md create mode 100644 .agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md create mode 100644 .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md create mode 100644 .agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md create mode 100644 .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md create mode 100644 .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md create mode 100644 packages/experimental/inspector/README.i18n.yaml create mode 100644 packages/experimental/inspector/README.md create mode 100644 packages/experimental/inspector/README.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml new file mode 100644 index 0000000000..505887522c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.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-23-cross-realm-cdp-inspector.md +2026-08-23-cross-realm-cdp-inspector.md: 2b4860a84982b0ed6bf749c6b408dbda9a16416e +2026-08-23-cross-realm-cdp-inspector.zh.md: 64e52fe9d57d7c30e9c16358f2b1b7345f815849 diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md new file mode 100644 index 0000000000..2b4860a849 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md @@ -0,0 +1,100 @@ +# Agent Note: Cross-realm CDP inspector + +Status: implemented + +English | [中文](2026-08-23-cross-realm-cdp-inspector.zh.md) + +## Problem + +Host diagnostics, browser Client observations, and JavaScript debugging originate in different JavaScript realms. A debugger transport implemented on the Host main thread cannot deliver `Debugger.resume` while that thread is paused, and a design that lets each producer emit CDP directly duplicates protocol state and couples application instrumentation to Chrome's presentation protocol. + +## Decision + +`@deepseek-ai/dsh-experimental-inspector` is one private Client/Host Cordis plugin package. Its Host face starts a Node Worker; its Client face connects directly to that Worker. Cordis owns composition, service publication, bootstrap injection, and disposal only. The source protocol, Worker state, CDP server, V8 bridge, and domain adapters do not inspect Cordis runtime data. + +The Worker is the sole CDP endpoint and the sole owner of CDP state. Host and Client producers send validated observations under a versioned internal protocol; Client Runtime, Console, Sources, and semantic queries use separate typed frame families on the same authenticated carrier. A realm registry gives every DevTools connection the same Runtime, Console, Sources, and Debugger capability slots, while explicit unsupported members preserve different Host and Client support levels. + +## Realm ownership + +The Host main thread owns application objects and `globalThis.fetch`. It sends observations over a dedicated `MessagePort` and never constructs CDP messages. + +The Client page owns browser observations, evaluated values, and Client object handles. It exchanges JSON frames directly with the Worker over an authenticated ingest WebSocket, so a paused Host does not stop Client delivery or Runtime execution. + +The Inspector Worker owns HTTP discovery, both WebSocket routes, source generations, retention, realm sessions, CDP sessions, and domain adapters. Each DevTools connection opens one backend session for the Host and every connected Client realm. V8 object ids remain inside the Node Runtime backend. Client object handles remain inside the typed Client protocol. One connection-local object table maps either backend handle to CDP object ids and projects the same RemoteObject, property, exception, Console, and paused-frame types. + +Chrome DevTools consumes one page-type target. Runtime methods route by execution context or object id. Debugger source methods route by script id; Host scripts retain native debugging, while Client scripts expose read-only content and reject active debugging. `Profiler` and `HeapProfiler` remain Host-only. `Network` and the minimal page-target scaffold run inside the Worker. + +## Source protocol + +Both MessagePort and WebSocket carriers use the same JSON value set and discriminated frames. A source identifies one logical producer and one connection generation, declares capabilities and topics, sends an initial replacement, then appends sequence-numbered batches. The Worker rejects malformed, oversized, stale-generation, and undeclared-topic frames before reading domain fields. + +Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects. + +Runtime frames use closed command and result unions instead of method strings with untyped parameter records. Every request carries a source id, source generation, DevTools Runtime session id, request id, and command. Every result repeats those identities and the command discriminant. Console lifecycle/events, chunked source reads, and non-CDP semantic queries have separate correlated frame families. RemoteObject values, previews, property descriptors, call arguments, exceptions, Console events, debugger frames, scripts, and errors have dedicated exact decoders. + +## Client Runtime, Console, and Sources + +`Runtime.enable` publishes the Host's real execution context and one negative-id synthetic execution context for each connected Client source that declares the Runtime capability. An omitted context continues to mean Host. Client source replacement destroys the old context and creates a new context with a new generation and unique id. + +The Client Runtime subset covers `Runtime.evaluate`, `Runtime.getProperties`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, `Runtime.releaseObject`, `Runtime.releaseObjectGroup`, and `Runtime.globalLexicalScopeNames`. The Client executes commands in its page realm and retains live objects in a table isolated by DevTools Runtime session. It returns opaque handles and JSON-safe metadata; the Worker validates the result and assigns a connection-local CDP object id. An object argument may be used only by the same Client source generation and DevTools session. Closing the source, disabling Runtime, closing DevTools, releasing an object, or releasing an object group removes the corresponding handles. + +JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state. + +The Client Console observer preserves the original page call and asynchronously emits one event per enabled DevTools session. Each session serializes arguments into its own `console` object group, so disconnect, Runtime disable, or `Runtime.discardConsoleEntries` can release one connection without invalidating another. Context and Fiber arguments use the same semantic reference and DOM reverse mapping as evaluation results. + +The Client discovers this package's `lib/client.js` URL from the assembled web boot graph. `Debugger.enable` reads metadata through a typed source operation, and `Debugger.getScriptSource` reassembles bounded base64 chunks; the source map remains available at the advertised URL. Client-script breakpoint, step, and call-frame operations remain explicitly unsupported because page JavaScript cannot pause its own realm and continue servicing control messages. Target-wide pause and resume continue to control the Host debugger. + +## Host debugging + +The Worker attaches each DevTools connection to the Host main isolate through its own Node inspector Session. Node Runtime, Console, Sources, and Debugger backends normalize native values and events into the same realm model used by Client backends. The common projector allocates connection-local object ids for evaluation results, Console arguments, paused scopes, and call-frame results. Breakpoint requests are translated back to native backend handles before reaching Node. The default context may receive the display name `Host` while retaining its real id and metadata. + +The Worker event loop, DevTools socket, Client ingest socket, and Node inspector Session remain runnable while Host JavaScript is paused. Host observations naturally stop until resume. + +## Fetch capture + +Fetch capture wraps `globalThis.fetch` and is enabled by default. Every later fetch records its complete URL, headers, request body, response headers, response body, timing, cancellation, and error. No field is redacted by default; using the inspector grants local DevTools access to those secrets. + +The wrapper passes a normalized Request to the original fetch, reads request and response clones on independent capture tasks, and returns the original Response as soon as fetch resolves. Capture failure never changes the caller's fetch result. Finite per-body and journal budgets prevent unbounded retention; exceeding a budget preserves the captured prefix and reports truncation. + +## Alternatives considered + +**Run the CDP server on the Host main thread.** Rejected because a breakpoint freezes the socket responsible for delivering `Debugger.resume`. + +**Relay Client observations through the Host web server.** Rejected because the relay also freezes at a Host breakpoint and makes the Client data path depend on Host responsiveness. + +**Let producers emit CDP messages.** Rejected because producer code would own Chrome-specific request ids, replay, enable state, and ordering instead of domain observations. + +**Share one Node inspector Session across DevTools clients.** Rejected because object ids, object groups, enable state, and debugger operations belong to one protocol session; sharing requires an error-prone virtual-session layer. + +**Send live Client objects or CDP object ids over WebSocket.** Rejected because JSON cannot preserve identity or behavior, and a CDP object id belongs to one DevTools session. Client-local handles plus a Worker-owned per-connection mapping preserve both ownership rules. + +**Use one untyped Runtime RPC method.** Rejected because method strings and arbitrary parameter objects cannot enforce command/result correlation, object-reference ownership, or exhaustive evolution as Runtime, Sources, and Debugger support grows. + +**Split protocol, Host, and Client into separate packages.** Rejected for the experimental phase. One package keeps the capability deployable as one Client/Host plugin while source directories and build entries preserve realm boundaries. + +**Use Undici diagnostics channels as the complete fetch source.** Rejected because they observe transport lifecycle but cannot provide complete request and response bodies without consuming application streams. They may later augment transport-level timing. + +## Verification + +- A real Worker accepts Host MessagePort and Client WebSocket sources and exposes both through one CDP target. +- Malformed, oversized, stale-generation, and sequence-gap frames cannot corrupt another source or the Worker. +- Console evaluates in the Host context and receives Host console events. +- Console lists Host and Client contexts; Client evaluation, properties, function calls, promise awaiting, and release operations preserve RemoteObject identity without sharing objects across realms or DevTools connections. +- Host and Client Console events use the same projector; Client arguments remain isolated by DevTools connection and Cordis arguments resolve to Elements nodes. +- Sources receives Host scripts and the built Client bundle; Client source reads are chunked and active debugging fails explicitly, while a breakpoint can pause the Host, evaluate a call frame, and resume. +- Host paused scopes and call-frame results use the same connection-local RemoteObject table as Runtime evaluation. +- Network replays requests that predate `Network.enable` and streams later requests without loss or duplication. +- Successful, failed, aborted, redirected, textual, binary, streaming, and truncated fetches preserve caller behavior and expose the configured captured data. +- Disposal stops capture, closes admission, disconnects V8 sessions, closes sockets, and waits for Worker exit before completing. + +## Consequences + +The Worker-owned endpoint keeps DevTools control responsive while Host JavaScript is paused and gives Host and Client observations one CDP state owner. That ownership adds the following security, resource, and compatibility costs. + +Full fetch capture intentionally exposes credentials and payloads to any local process that can attach to the CDP endpoint. Loopback binding is mandatory but is not authentication. + +Cloning request and response streams adds CPU, memory, and I/O pressure. Finite limits bound retained bytes but cannot make full capture free. + +A page-type synthetic target depends on a small set of Chrome DevTools compatibility responses outside Node's native inspector domains. Each no-op must be named and covered because silently accepting every unknown method hides protocol drift. + +Client Runtime execution uses page JavaScript evaluation, so page Content Security Policy may reject it and native DevTools command-line or REPL semantics are not promised. Read-only Client Sources do not imply active Client debugging; adding that capability requires an execution agent that remains responsive while the inspected page realm is paused. diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md new file mode 100644 index 0000000000..64e52fe9d5 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md @@ -0,0 +1,100 @@ +# Agent Note: 跨 realm CDP Inspector + +Status: implemented + +[English](2026-08-23-cross-realm-cdp-inspector.md) | 中文 + +## Problem + +Host 诊断、浏览器 Client 观测和 JavaScript 调试来自不同 JavaScript realm。Host 主线程上的 debugger transport 无法在该线程暂停时投递 `Debugger.resume`;若每个 producer 直接生成 CDP,又会重复协议状态,并把应用观测逻辑绑到 Chrome 呈现协议。 + +## Decision + +`@deepseek-ai/dsh-experimental-inspector` 是一个私有 Client/Host 双面 Cordis 插件包。Host 面启动 Node Worker,Client 面直接连接该 Worker。Cordis 只负责组合、服务发布、bootstrap 注入与 dispose;source 协议、Worker 状态、CDP server、V8 bridge 和 domain adapter 不检查 Cordis 运行时数据。 + +Worker 是唯一 CDP endpoint,也是 CDP 状态的唯一 owner。Host 与 Client producer 通过有版本的内部协议发送验证后的观测记录;Client Runtime、Console、Sources 和语义查询在同一条鉴权 carrier 上使用相互独立的类型化帧。realm registry 为每条 DevTools 连接提供相同的 Runtime、Console、Sources 和 Debugger capability slot,并用明确的 unsupported 成员保留 Host 与 Client 的支持差异。 + +## Realm 所有权 + +Host 主线程拥有应用对象和 `globalThis.fetch`。它通过专用 `MessagePort` 发送观测记录,绝不构造 CDP 消息。 + +Client 页面拥有浏览器观测、求值得到的值和 Client object handle。它通过带鉴权的 ingest WebSocket 直接与 Worker 交换 JSON 帧,因此 Host 暂停不会阻断 Client 投递或 Runtime 执行。 + +Inspector Worker 拥有 HTTP discovery、两条 WebSocket route、source generation、保留历史、realm session、CDP session 和 domain adapter。每条 DevTools 连接为 Host 和每个已连接 Client realm 分别建立一套 backend session。V8 object id 只留在 Node Runtime backend 内。Client object handle 只留在类型化 Client 协议内。单个 connection-local object table 把两类 backend handle 映射成 CDP object id,并投影同一种 RemoteObject、property、exception、Console 与 paused-frame 类型。 + +Chrome DevTools 消费一个 page 类型 target。Runtime 方法按 execution context 或 object id 路由;Debugger source 方法按 script id 路由。Host script 保留原生调试,Client script 只暴露只读内容,并拒绝 active debugging。`Profiler` 与 `HeapProfiler` 仍然只属于 Host;`Network` 与最小 page-target scaffold 在 Worker 内执行。 + +## Source 协议 + +MessagePort 与 WebSocket carrier 使用同一组 JSON 值和判别联合帧。source 标识一个逻辑 producer 和一个连接 generation,声明 capability 与 topic,发送初始 replace,再追加带 sequence 的 batch。Worker 在读取 domain 字段前拒绝畸形、超限、旧 generation 和未声明 topic 的帧。 + +投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。 + +Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符串加无类型 parameter record。每个 request 携带 source id、source generation、DevTools Runtime session id、request id 和 command;每个 result 重复这些身份与 command 判别符。Console lifecycle/event、分块 source 读取和非 CDP 语义查询使用各自独立的关联帧。RemoteObject value、preview、property descriptor、call argument、exception、Console event、debugger frame、script 与 error 都有独立的精确 decoder。 + +## Client Runtime、Console 与 Sources + +`Runtime.enable` 发布 Host 的真实 execution context,并为每个声明 Runtime 能力的已连接 Client source 发布一个负数 id synthetic execution context。不指定 context 仍然表示 Host。Client source replacement 会销毁旧 context,并以新的 generation 与 unique id 创建新 context。 + +Client Runtime 子集包括 `Runtime.evaluate`、`Runtime.getProperties`、`Runtime.callFunctionOn`、`Runtime.awaitPromise`、`Runtime.releaseObject`、`Runtime.releaseObjectGroup` 和 `Runtime.globalLexicalScopeNames`。Client 在页面 realm 中执行命令,并在按 DevTools Runtime session 隔离的表中保留实时对象。Client 只返回不透明 handle 与 JSON-safe metadata;Worker 验证结果并分配连接私有的 CDP object id。对象参数只能由同一 Client source generation 与 DevTools session 使用。source 断开、Runtime disable、DevTools 关闭、释放对象或释放 object group 都会移除对应 handle。 + +JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。 + +Client Console observer 保持原始页面调用行为,并为每个已启用的 DevTools session 异步发出一份 event。每个 session 把 argument 序列化到自己的 `console` object group,因此断联、Runtime disable 或 `Runtime.discardConsoleEntries` 可以释放一条连接而不使其他连接失效。Context 与 Fiber argument 使用和求值结果相同的语义引用及 DOM 反向映射。 + +Client 从组装后的 web boot graph 发现本包 `lib/client.js` 的 URL。`Debugger.enable` 通过类型化 source operation 读取 metadata,`Debugger.getScriptSource` 重组有界 base64 chunk;source map 保持在公布的 URL 上可用。Client script breakpoint、step 与 call-frame 操作明确不受支持,因为页面 JavaScript 无法暂停自身 realm 后继续处理控制消息。target-wide pause 与 resume 继续控制 Host debugger。 + +## Host 调试 + +Worker 为每条 DevTools 连接建立独立 Node inspector Session,并连接 Host 主 isolate。Node Runtime、Console、Sources 与 Debugger backend 把原生 value 和 event 归一化成 Client backend 使用的同一种 realm model。公共 projector 为求值结果、Console argument、paused scope 和 call-frame result 分配 connection-local object id。breakpoint request 到达 Node 前会反向转换成原生 backend handle。默认 context 可以改显示名为 `Host`,但保留真实 id 和 metadata。 + +Host JavaScript 暂停时,Worker event loop、DevTools socket、Client ingest socket 与 Node inspector Session 仍可运行。Host 观测自然暂停到 resume。 + +## Fetch 采集 + +fetch 采集包装 `globalThis.fetch`,并默认开启。之后每次 fetch 都记录完整 URL、headers、请求体、响应 headers、响应体、时间、取消与错误。默认不脱敏任何字段;启用 Inspector 即把这些秘密交给本机 DevTools。 + +wrapper 把标准化 Request 交给原 fetch,通过独立采集任务读取 request/response clone,并在 fetch resolve 后立即把原始 Response 交给调用方。采集失败不得改变调用方的 fetch 结果。有限的单体与 journal 预算阻止无界保留;超过预算时保留已采集前缀并报告截断。 + +## Alternatives considered + +**在 Host 主线程运行 CDP server。** 拒绝,因为断点会冻结负责投递 `Debugger.resume` 的 socket。 + +**经 Host web server 中转 Client 观测。** 拒绝,因为 Host 断点同样冻结中转,并使 Client 数据路径依赖 Host 响应。 + +**让 producer 直接生成 CDP 消息。** 拒绝,因为 Chrome 专用 request id、回放、enable 状态和排序会落入 producer,而不是领域观测。 + +**多个 DevTools client 共用一个 Node inspector Session。** 拒绝,因为 object id、object group、enable 状态与 debugger 操作属于单个协议 session;共享需要易错的虚拟 session 层。 + +**通过 WebSocket 发送 Client 实时对象或 CDP object id。** 拒绝,因为 JSON 无法保留对象身份或行为,而 CDP object id 只属于一条 DevTools session。Client-local handle 加 Worker 所有的逐连接映射同时维护这两条所有权规则。 + +**使用一个无类型 Runtime RPC method。** 拒绝,因为 method 字符串和任意 parameter object 无法保证 command/result 关联、对象引用所有权,也无法在 Runtime、Sources 与 Debugger 支持增长时做穷尽演进。 + +**把 protocol、Host 与 Client 拆成多个包。** 实验阶段拒绝。一个包保持能力以一个 Client/Host 插件部署,同时由源码目录与构建入口维护 realm 边界。 + +**用 Undici diagnostics channel 作为完整 fetch 数据源。** 拒绝,因为它能观察 transport lifecycle,却无法在不消费应用 stream 的前提下提供完整 request/response body。后续可以用它补充 transport 级 timing。 + +## Verification + +- 真实 Worker 同时接收 Host MessagePort 与 Client WebSocket source,并通过一个 CDP target 暴露两者。 +- 畸形、超限、旧 generation 与 sequence gap 帧不会破坏其他 source 或 Worker。 +- Console 在 Host context 求值并接收 Host console event。 +- Console 列出 Host 与 Client context;Client 求值、属性、函数调用、Promise await 与释放操作维持 RemoteObject 身份,且不在 realm 或 DevTools 连接之间共享对象。 +- Host 与 Client Console event 使用相同 projector;Client argument 按 DevTools 连接隔离,Cordis argument 可以解析到 Elements node。 +- Sources 接收 Host script 与构建后的 Client bundle;Client source 读取采用分块传输,active debugging 明确失败,而 Host 仍可被断点暂停、求值 call frame 并 resume。 +- Host paused scope 与 call-frame result 使用和 Runtime 求值相同的 connection-local RemoteObject table。 +- Network 回放 `Network.enable` 前的请求,并无遗漏、无重复地推送后续请求。 +- 成功、失败、取消、重定向、文本、二进制、流式与截断 fetch 都保持调用方行为,并暴露配置允许的完整采集数据。 +- dispose 停止采集、关闭入口、断开 V8 session、关闭 socket,并等待 Worker exit 后完成。 + +## Consequences + +Worker 所有的 endpoint 在 Host JavaScript 暂停时仍保持 DevTools 控制可响应,并让 Host 与 Client 观测共享唯一 CDP 状态 owner。这项所有权带来以下安全、资源与兼容性成本。 + +完整 fetch 采集会有意把 credential 和 payload 暴露给任何能连接 CDP endpoint 的本机进程。loopback 监听是强制要求,但不是鉴权。 + +clone request/response stream 会增加 CPU、内存与 I/O 压力。有限预算能约束保留字节,不能让完整采集没有成本。 + +page 类型 synthetic target 依赖 Node 原生 inspector domain 之外的一组 Chrome DevTools 兼容响应。每个 no-op 都必须明确命名并有测试;统一吞掉未知方法会掩盖协议漂移。 + +Client Runtime 执行使用页面 JavaScript 求值,因此页面 Content Security Policy 可能拒绝它,也不承诺原生 DevTools command-line 或 REPL 语义。只读 Client Sources 不代表 active Client debugging;增加该能力需要一个在被检查页面 realm 暂停时仍能响应的执行 agent。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml new file mode 100644 index 0000000000..baaec4c1f1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.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-24-cordis-runtime-tree-inspection.md +2026-08-24-cordis-runtime-tree-inspection.md: 49a0bb4de34cf2a3c9f2943b432a62051ae85a9d +2026-08-24-cordis-runtime-tree-inspection.zh.md: b08b8fcb8d2bbed624be4dd5273406c856393e62 diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md new file mode 100644 index 0000000000..49a0bb4de3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md @@ -0,0 +1,113 @@ +# Agent Note: Cordis runtime tree inspection + +Status: implemented + +English | [中文](2026-08-24-cordis-runtime-tree-inspection.zh.md) + +## Problem + +The Inspector needs to present each Host and Client Cordis runtime as a tree in Chrome DevTools Elements. A Cordis Context or Fiber selected in Elements must also behave as a live Runtime object, while a Cordis object printed in Console must be revealable as the same semantic node. CDP identifiers cannot be the source model: `NodeId`, `BackendNodeId`, and `RemoteObjectId` have different owners and lifetimes, and a future model-facing runtime query must consume the same Cordis data without translating CDP. + +Host and Client run the same Cordis abstractions in different JavaScript realms. Tree discovery and classification must therefore be one browser-safe implementation, while object resolution remains realm-local and only opaque references cross MessagePort or WebSocket boundaries. + +## Decision + +The Inspector uses one serialized Cordis tree model and keeps CDP as one adapter over it. The package separates live-object discovery, immutable snapshots, Worker-owned storage, and consumers: + +The existing [cross-realm Inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) owns the Worker, source carriers, Runtime routing, and security model. This note owns only the Cordis semantic data and its consumers. + +```text +Host Context/Fiber ─┐ + ├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter +Client Context/Fiber┘ └─ future model adapter +``` + +`CordisTreeCollector` and its identity registry are browser-safe modules compiled into both package faces. Host and Client instantiate that same code against their own `ctx.root`; neither side carries a second classification implementation. + +## Cordis tree model + +`CordisTreeSnapshot` is a CDP-independent, lossless-JSON value with a schema version, monotonically increasing revision, object-registry id, truncation flag, and one nested root Context. Context nodes contain an opaque object handle and ordered Context/Fiber children. Fiber nodes contain their Cordis `uid`, an opaque object handle, and exactly one Context child representing `fiber.ctx`. Host and Client publish this same realm-tree type. No generated Context id, plugin metadata, service data, arbitrary property value, or object preview enters the tree. + +The inspection tree starts at the root Context and omits the Cordis root Fiber. For every other plugin, its parent Context contains the Fiber and that Fiber contains its owned Context. A Context created by `extend()`, `isolate()`, or `intercept()` without a new Fiber remains a direct Context child. Nesting expresses parentage without generated node ids and preserves both object identities without introducing the `Fiber.ctx` / `Context.fiber` cycle. + +The collector starts from the root, every live registry Fiber, and every event hook's owning Context. It follows Context prototype links back to the inspected root, unwraps Cordis shadow contexts, deduplicates by object identity, and excludes disposed fibers. `internal/plugin` and `internal/status` events schedule one microtask-coalesced replacement snapshot. Node-count and encoded-byte limits remove complete trailing branches, so every retained node still has its parent and every retained Fiber still has its owned Context. + +## Identity and lifetime + +The identities are intentionally distinct: + +- Fiber `uid` comes from Cordis. Context currently has no Cordis-owned id and the Inspector does not expose a generated substitute. +- `InspectorObjectReference` is an opaque realm-local handle resolving a tree node to its live Context or Fiber. Snapshots carry the handle for routing, never as a semantic id or DOM attribute. +- `BackendNodeId` is assigned by the Worker to one retained `(source id, source generation, object reference)` and is shared by DevTools connections while that generation's snapshot is retained. +- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It is discarded on `DOM.documentUpdated` or connection close. +- `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group. + +`sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated. + +Standard CDP does not place a `RemoteObjectId` field on `DOM.Node`. `DOM.Node` carries `nodeId` and `backendNodeId`; `DOM.resolveNode` returns the corresponding `Runtime.RemoteObject`, and `DOM.requestNode` performs the reverse mapping. The implementation keeps these three CDP identities correlated without adding non-standard DOM fields. + +## Realm object bridge + +Each collector registers a realm-local object table under a private global symbol. The table maps opaque handles to live objects and can identify a currently retained object by identity. Replacing a snapshot removes handles absent from the new tree; disposing the observer unregisters the table. + +For Host nodes, the Worker uses that DevTools connection's private `node:inspector.Session` to evaluate a lookup in the Host table, producing a native V8 `RemoteObjectId`. For Client nodes, the Worker routes the same lookup through the existing typed Client Runtime channel and maps the returned Client handle to a connection-local CDP object id. No live object or engine object id crosses a source transport. + +Client Runtime values carry an optional validated `InspectorObjectReference`, while Host Runtime values are probed through their native V8 object id. The common CDP adapter changes recognized evaluation results, properties, exceptions, Console arguments, and paused-frame objects to `subtype: "node"`, records the object-id-to-backend-node relation, and supplies the Cordis element description. This gives both directions: Elements can expose a live object, and a Context or Fiber returned or printed in Console can be revealed in Elements. + +## Worker repository and updates + +Sources publish the Cordis tree as retained state rather than an event history. Host MessagePort and Client WebSocket publishers keep the latest state record and include it in `source/replace` after admission, reconnection, or a resnapshot request. Live replacements still use the ordinary sequenced append path. The Worker validates every snapshot for exact fields, bounded node count and depth, unique object handles and Fiber uids, a Context root, and exactly one Context child per Fiber before atomically replacing the prior tree. + +`CordisTreeStore` owns validated realm snapshots and source lifecycle only. Its internal reader retains live object routes for Runtime and DOM, while its public reader projects a detached `{ host, clients }` tree without transport or CDP ids. Host and Client `ctx.inspector.cordis.getTree()` calls use the same correlated query protocol and Worker reader without creating a CDP session. `CordisDomBackend` adds Worker-global backend ids, while each `CordisDomSession` owns frontend node ids, searches, enabled state, and RemoteObject correlations. A model adapter can consume the public reader without depending on DOM serialization or debugger activation. + +Closing a source changes its stored tree from connected to disconnected instead of deleting the last snapshot. Object lookup excludes disconnected trees, so the snapshot remains inspectable as data without retaining or reviving a live Context, Fiber, or Runtime object. A replacement from the same source id and a new transport generation atomically restores the connected state. The configurable disconnected-tree limit evicts the oldest retained snapshots. + +Accepted tree replacements emit `DOM.documentUpdated` and require the frontend to pull a fresh document. A disconnect that does not evict another tree invalidates object routes without changing the DOM document, preserving the loaded tree, expansion, and selection. Connection state remains in the inspection model until its Elements presentation is designed. Retention eviction falls back to `DOM.documentUpdated`. Further incremental tree diffs can be added behind `CordisDomSession` without changing collectors, snapshots, or model consumers. + +## CDP projection + +The synthetic document has a `` container and a `` container. `` contains the Host root Context. `` contains one `` per Client source, and each `` contains that realm's root Context. These structural elements have no Runtime object or attributes. Context elements have no attributes. Fiber elements expose only `uid`, copied without reinterpretation from Cordis. Connected Context and Fiber elements resolve to live RemoteObjects; disconnected snapshots retain their DOM nodes but object resolution fails. + +Standard CDP has no backend-controlled frozen, locked, or dimmed state for a node in the ordinary Elements tree. Chromium's detached-node presentation is frontend-local to the Memory panel's `DOM.getDetachedDomNodes` flow. No connection-state attribute or non-standard `DOM.Node` field is added until its presentation is decided. + +The read-only adapter implements document retrieval, child requests, node description, attributes, outer HTML, search, backend-id pushes, node resolution, and reverse object lookup. Mutating DOM methods fail explicitly. Layout, CSS, accessibility, and browser DOM geometry are outside this semantic tree and return empty or unsupported responses only where Chrome DevTools requires a compatibility response. + +## Alternatives considered + +**Build CDP DOM nodes directly in each realm.** Rejected because Host and Client would duplicate classification, frontend ids would leak into source protocols, and a model consumer would need to reverse a presentation protocol back into Cordis concepts. + +**Send live objects or V8 object ids to the Worker.** Rejected because structured clone and JSON do not preserve identity or behavior, and engine object ids belong to one inspector session. + +**Generate an Inspector Context id.** Rejected because Cordis Context has no intrinsic id and a presentation adapter must not make an implementation key look like framework identity. Nested children express parentage; opaque object handles remain routing data. + +**Use one id for Fiber uid, backend nodes, and frontend nodes.** Rejected because source reconnection, multiple DevTools connections, document refresh, and Runtime object release have independent lifetimes. + +**Expose only Contexts and treat each Fiber-owned Context as the Fiber.** Rejected because it loses one of the two live objects, makes Console identity ambiguous, and prevents later Fiber-specific properties from having a stable owner. + +**Put the model-facing API on the CDP adapter.** Rejected because model access would inherit Chrome-specific node serialization, per-connection ids, and enable state. The Worker repository is the shared source; CDP and model access are sibling adapters. + +**Remove a realm tree when its source disconnects.** Rejected because transport loss would discard the last useful topology and collapse the user's Elements inspection state. Keeping old object handles usable was also rejected: a new connection generation cannot prove that any prior live object still exists. + +## Verification + +- The same collector implementation produces Host and Client snapshots from equivalent Cordis runtimes. +- Elements shows `` and `/` containers with each realm's root Context directly beneath its container. +- Context elements have no attributes; Fiber elements expose only their Cordis `uid`; the root Fiber is absent. +- Every connected Context and Fiber has a connection-local frontend node id, a Worker backend node id, and a resolvable connection-local Runtime object id without exposing them as attributes. +- `DOM.resolveNode` and `DOM.requestNode` round-trip Context and Fiber identities without sharing object ids across DevTools connections or source generations. +- A Context or Fiber returned by Runtime evaluation is node-branded and can be revealed in Elements. +- Disconnect destroys the Client execution context and its RemoteObjects while retaining the last Elements tree unchanged; a new transport generation replaces it after a complete snapshot arrives. +- Reconnect and resnapshot replay the latest tree state; malformed or oversized replacements do not replace the last valid snapshot. +- The stored snapshot and query API contain no CDP types and can support a future model-facing adapter unchanged. + +## Consequences + +Cordis exposes no complete global Context registry. The collector can recover contexts reachable from live fibers and event hooks; a context that is created, never used, and retained only by application code is intentionally absent. + +Object recognition adds a Runtime round trip for each Host object that requires semantic identification. An annotation failure leaves an ordinary RemoteObject rather than breaking Runtime or Debugger delivery. Client Console observation preserves the original method result and schedules serialization afterward; each enabled DevTools session receives independently retained handles, so recognition never blocks the page call or shares objects between connections. + +Full-tree replacement is simpler than incremental source mutations but can become expensive in very large runtimes. Node and byte limits preserve a valid prefix and report truncation; a later delta protocol can replace the transport without changing the snapshot model. + +The object table intentionally keeps every object in the current visible tree strongly reachable until the next replacement or observer disposal. This is bounded by the retained snapshot and must not become a general-purpose object registry. + +The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory and may force a full Elements document refresh when an older disconnected tree is evicted. diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md new file mode 100644 index 0000000000..b08b8fcb8d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md @@ -0,0 +1,113 @@ +# Agent Note: Cordis 运行时树检查 + +Status: implemented + +[English](2026-08-24-cordis-runtime-tree-inspection.md) | 中文 + +## Problem + +Inspector 需要在 Chrome DevTools Elements 中把每个 Host 和 Client Cordis 运行时呈现为一棵树。在 Elements 中选中的 Cordis Context 或 Fiber 也必须表现为实时 Runtime 对象,而 Console 中打印出的 Cordis 对象必须能定位回同一个语义节点。CDP id 不能成为源模型:`NodeId`、`BackendNodeId` 与 `RemoteObjectId` 的 owner 和生命周期不同,并且未来面向模型的运行时查询必须使用同一份 Cordis 数据,而不是再反向解析 CDP。 + +Host 与 Client 在不同 JavaScript realm 中运行相同的 Cordis 抽象。因此,树发现与分类必须只有一份浏览器安全实现;对象解析仍留在各自 realm 内,跨 MessagePort 或 WebSocket 只传递不透明引用。 + +## Decision + +Inspector 使用一套序列化 Cordis 树模型,并把 CDP 作为它的一个适配器。包内分离实时对象发现、不可变快照、Worker 存储与消费方: + +现有的[跨 realm Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、source carrier、Runtime 路由与安全模型;本 Note 只负责 Cordis 语义数据及其消费方。 + +```text +Host Context/Fiber ─┐ + ├─ CordisTreeCollector ─ CordisTreeSnapshot ─ source transport ─ CordisTreeStore ─┬─ CDP DOM adapter +Client Context/Fiber┘ └─ future model adapter +``` + +`CordisTreeCollector` 及其身份注册表是浏览器安全模块,同时编入包的两个运行面。Host 与 Client 针对各自的 `ctx.root` 实例化同一份代码;任何一侧都不维护第二套分类实现。 + +## Cordis tree model + +`CordisTreeSnapshot` 是与 CDP 无关的无损 JSON 值,包含 schema 版本、单调递增 revision、对象注册表 id、截断标志和一棵以 Context 为根的嵌套树。Context 节点包含不透明 object handle 与有序的 Context/Fiber children。Fiber 节点包含 Cordis `uid`、不透明 object handle,以及唯一一个表示 `fiber.ctx` 的 Context child。Host 与 Client 发布同一种 realm-tree 类型。生成的 Context id、插件 metadata、服务数据、任意属性值与对象 preview 都不进入树。 + +inspection tree 从 root Context 开始,不包含 Cordis root Fiber。对其他每个插件,其 parent Context 包含 Fiber,该 Fiber 再包含它拥有的 Context。通过 `extend()`、`isolate()` 或 `intercept()` 创建且未创建新 Fiber 的 Context 仍是直接 Context 子节点。嵌套结构无需生成 node id 即可表达 parent,并保留两类对象身份,同时避免把 `Fiber.ctx` / `Context.fiber` 环写入序列化树。 + +collector 从 root、注册表中的每个 live Fiber,以及每个 event hook 的 owner Context 开始。它沿 Context prototype 链回溯到被检查的 root,解开 Cordis shadow Context,按对象身份去重,并排除已 dispose 的 Fiber。`internal/plugin` 与 `internal/status` 事件调度一次 microtask 合并后的 replacement snapshot。节点数和编码字节数限制会移除完整的尾部 branch,因此每个保留节点仍有 parent,每个保留 Fiber 仍有其 owned Context。 + +## Identity and lifetime + +各类身份刻意保持独立: + +- Fiber `uid` 来自 Cordis。Context 当前没有 Cordis 自有 id,Inspector 不会暴露一个生成值来替代。 +- `InspectorObjectReference` 是 realm 本地的不透明 handle,用于把树节点解析成实时 Context 或 Fiber。snapshot 携带该 handle 只为完成路由,不把它当成语义 id 或 DOM attribute。 +- `BackendNodeId` 由 Worker 为一条保留的 `(source id, source generation, object reference)` 分配,并在该 generation 的 snapshot 被保留期间由所有 DevTools 连接共享。 +- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;`DOM.documentUpdated` 或连接关闭时丢弃。 +- `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。 + +`sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。 + +标准 CDP 不会在 `DOM.Node` 上放置 `RemoteObjectId` 字段。`DOM.Node` 携带 `nodeId` 与 `backendNodeId`;`DOM.resolveNode` 返回对应的 `Runtime.RemoteObject`,`DOM.requestNode` 执行反向映射。实现会关联这三类 CDP 身份,而不添加非标准 DOM 字段。 + +## Realm object bridge + +每个 collector 都在私有 global symbol 下注册一个 realm 本地对象表。该表把不透明 handle 映射到实时对象,并能按身份识别当前保留的对象。替换快照时会移除新树中不存在的 handle;dispose observer 时注销该表。 + +对 Host 节点,Worker 使用该 DevTools 连接私有的 `node:inspector.Session` 在 Host 对象表中执行查询,从而生成原生 V8 `RemoteObjectId`。对 Client 节点,Worker 通过已有的类型化 Client Runtime channel 路由同一查询,再把返回的 Client handle 映射为连接本地 CDP object id。实时对象和引擎 object id 都不会穿过 source transport。 + +Client Runtime value 携带一个可选、已验证的 `InspectorObjectReference`,Host Runtime value 则通过原生 V8 object id 探测。公共 CDP adapter 把已识别的 evaluation result、property、exception、Console argument 和 paused-frame object 改成 `subtype: "node"`,记录 object-id 到 backend-node 的关系,并提供 Cordis element description。这样两个方向都成立:Elements 可以暴露实时对象,Console 中返回或打印的 Context 与 Fiber 也能定位到 Elements。 + +## Worker repository and updates + +source 把 Cordis 树作为保留状态发布,而不是事件历史。Host MessagePort 与 Client WebSocket publisher 保留最新状态记录,并在接纳、重连或收到 resnapshot 请求后把它放入 `source/replace`。实时 replacement 仍走普通的有序 append 路径。Worker 在原子替换旧树前验证 snapshot 的精确字段、节点数与深度限制、object handle 与 Fiber uid 唯一性、Context root,以及每个 Fiber 恰好拥有一个 Context child。 + +`CordisTreeStore` 只拥有已验证 realm snapshot 和 source 生命周期。内部 reader 为 Runtime 与 DOM 保留 live object route;公共 reader 则投影一棵不含 transport 或 CDP id 的 detached `{ host, clients }` tree。Host 与 Client 的 `ctx.inspector.cordis.getTree()` 通过同一套关联查询协议读取同一个 Worker reader,不创建 CDP session。`CordisDomBackend` 增加 Worker 全局 backend id,每个 `CordisDomSession` 则拥有 frontend node id、搜索、enable 状态和 RemoteObject 关联。模型 adapter 可以消费公共 reader,而不依赖 DOM 序列化或 debugger activation。 + +source 关闭时,存储的树从 connected 变为 disconnected,而不是删除最后一份 snapshot。对象查询会排除 disconnected 树,因此 snapshot 仍可作为数据检查,但不会保留或复活实时 Context、Fiber 或 Runtime object。同一 source id 的新 transport generation 提交 replacement 后,会原子恢复 connected 状态。可配置的 disconnected tree 数量上限会淘汰最早保留的 snapshot。 + +接受 tree replacement 后发送 `DOM.documentUpdated`,要求 frontend 重新拉取 document。如果断联没有淘汰另一棵树,则只失效对象路由,不改变 DOM document,从而保留已加载的树、展开状态与选择。连接状态留在 inspection model 中,等待其 Elements 展示方式被明确设计。保留上限触发淘汰时回退到 `DOM.documentUpdated`。以后可以在 `CordisDomSession` 内增加更多增量 DOM diff,而无需修改 collector、snapshot 或模型消费方。 + +## CDP projection + +synthetic document 包含一个 `` container 和一个 `` container。`` 包含 Host root Context;`` 为每个 Client source 包含一个 ``,每个 `` 再包含该 realm 的 root Context。这些结构 element 没有 Runtime object 或 attribute。Context element 没有 attribute。Fiber element 只暴露从 Cordis 原样复制的 `uid`。connected Context 与 Fiber 可以解析为 live RemoteObject;disconnected snapshot 保留 DOM node,但对象解析失败。 + +标准 CDP 没有可由 backend 控制、用于普通 Elements 树节点的 frozen、locked 或 dimmed 状态。Chromium 的 detached-node 展示只存在于 Memory 面板的 `DOM.getDetachedDomNodes` 流程,并由 frontend 本地设置。在展示方式明确前,不增加 connection-state attribute 或非标准 `DOM.Node` 字段。 + +只读适配器实现 document 获取、子节点请求、节点描述、属性、outer HTML、搜索、backend-id push、节点解析和对象反向查询。修改型 DOM 方法明确失败。layout、CSS、accessibility 与浏览器 DOM geometry 不属于这棵语义树;仅在 Chrome DevTools 需要兼容响应时返回空结果或 unsupported。 + +## Alternatives considered + +**在每个 realm 直接构建 CDP DOM node。** 拒绝,因为 Host 与 Client 会重复分类,frontend id 会泄漏进 source 协议,模型消费方还必须把展示协议反向解析成 Cordis 概念。 + +**把实时对象或 V8 object id 发送给 Worker。** 拒绝,因为 structured clone 与 JSON 无法保留身份或行为,而且引擎 object id 只属于一个 inspector session。 + +**由 Inspector 生成 Context id。** 拒绝,因为 Cordis Context 没有自身 id,展示适配器不能把实现 key 伪装成框架身份。嵌套 children 表达 parent,不透明 object handle 只作为路由数据。 + +**Fiber uid、backend node 与 frontend node 共用一个 id。** 拒绝,因为 source 重连、多条 DevTools 连接、document refresh 与 Runtime object release 的生命周期彼此独立。 + +**只暴露 Context,并把每个 Fiber 拥有的 Context 当成 Fiber。** 拒绝,因为这会丢失两类实时对象中的一类,使 Console 身份产生歧义,并让后续 Fiber 专属属性失去稳定 owner。 + +**把模型访问 API 放在 CDP 适配器上。** 拒绝,因为模型访问会继承 Chrome 专用 node 序列化、逐连接 id 和 enable 状态。Worker repository 是共享数据源,CDP 与模型访问是并列适配器。 + +**source 断联时移除 realm 树。** 拒绝,因为传输中断会丢失最后一份有用拓扑,并折叠用户在 Elements 中的检查状态。继续使用旧 object handle 同样不可接受:新的连接 generation 无法证明任何先前实时对象仍然存在。 + +## Verification + +- 同一个 collector 实现能从等价 Cordis 运行时生成 Host 和 Client 快照。 +- Elements 显示 `` 与 `/` container,每个 realm 的 root Context 直接位于其 container 下。 +- Context element 不含 attribute;Fiber element 只暴露 Cordis `uid`;root Fiber 不出现。 +- 每个 connected Context 与 Fiber 都有一个连接本地 frontend node id、一个 Worker backend node id 和一个可解析的连接本地 Runtime object id,且它们都不作为 attribute 暴露。 +- `DOM.resolveNode` 与 `DOM.requestNode` 能往返映射 Context/Fiber 身份,且不会跨 DevTools 连接或 source generation 共享 object id。 +- Runtime evaluation 返回的 Context 或 Fiber 会被标记为 node,并能在 Elements 中定位。 +- 断联会销毁 Client execution context 与 RemoteObject,同时原样保留最后一棵 Elements 树;新的 transport generation 在完整 snapshot 到达后替换它。 +- 重连和 resnapshot 会重放最新树状态;畸形或超限 replacement 不会替换最后一个有效快照。 +- 存储的 snapshot 与查询 API 不包含 CDP 类型,可以不加修改地支持未来的模型适配器。 + +## Consequences + +Cordis 不提供完整的全局 Context registry。collector 能恢复从 live fiber 与 event hook 可达的 Context;一个已创建、从未使用且只由应用代码保留的 Context 会有意缺席。 + +需要语义识别的每个 Host object 都会增加一次 Runtime round trip。annotation 失败时保留普通 RemoteObject,不破坏 Runtime 或 Debugger 投递。Client Console observation 保留原始 method result,并在之后调度序列化;每个已启用 DevTools session 独立保留 handle,因此识别既不阻塞页面调用,也不在连接间共享对象。 + +完整树 replacement 比增量 source mutation 更简单,但在超大运行时中可能昂贵。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换为 delta 协议,而不修改 snapshot model。 + +对象表会有意强引用当前可见树中的每个对象,直到下一次 replacement 或 observer dispose。该集合受保留快照限制,不能扩展成通用对象注册表。 + +Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰较早的断联树时,Elements document 可能需要完整刷新。 diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml new file mode 100644 index 0000000000..31a09c0e9a --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md +2026-08-26-inspector-execution-realms-and-protocol-planes.md: 67f37add1a788a450643a362fac24426ff80a43e +2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: be4dd947fd999d0b9c09f7b4c821390b5b3b1245 diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md b/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md new file mode 100644 index 0000000000..67f37add1a --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md @@ -0,0 +1,104 @@ +# Agent Note: Inspector execution realms and protocol planes + +Status: proposed + +English | [中文](2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md) + +## Problem + +The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Its current source tree mixes execution ownership with feature names: browser and Host producers have unrelated layouts, Worker-executed Client and Host backends sit under a generic backend directory, and one protocol directory combines transport frames, Cordis data, network observations, and CDP-oriented Runtime values. A file path therefore does not establish where code runs or which identifiers it may own. + +This ambiguity is risky because Host and Client support intentionally differs while their architecture must remain comparable. Host Runtime and Debugger delegate to Node's inspector protocol; Client Runtime and Console simulate the same backend semantics over an internal bridge. If their files, interfaces, and unsupported operations diverge structurally, each new protocol method encourages a second routing model. Likewise, consumers that only need the Cordis runtime tree must not inherit debugger activation, Chrome connection state, or CDP identifiers. + +The [cross-realm CDP inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) continues to own Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](../../implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md) continues to own Cordis tree semantics, object routing, and DOM projection. This proposal owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state. + +## Proposal + +Top-level source directories will identify execution ownership. `client/` will contain only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client still belongs under `worker/`, not `client/`. + +The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling. + +```text +src/ + shared/ environment-independent data and interfaces + client/ browser Client producer and adapters + host/ Host Node-main-thread producer and adapters + worker/ Worker transport, repositories, realm backends, and CDP endpoint +``` + +`client/` and `host/` will have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support. + +Worker-side realm adapters will use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers. + +## Execution ownership + +`client/` owns page-realm observation, Client object handles, browser evaluation, browser Console interception, Client source publication, and its direct authenticated bridge to the Worker. It may use browser APIs but not Node or Worker implementation modules. + +`host/` owns Cordis plugin composition on the Node main thread, Worker startup and disposal, Host object observation, fetch capture, Node inspector notification forwarding, and the Host side of the Worker bridge. It may use Node APIs but does not construct Chrome CDP responses. + +`worker/bridge/` owns source admission, transport endpoints, connection generations, frame dispatch, correlation, and routing between source producers and Worker consumers. `worker/inspection/` owns retained Cordis and network observations plus transport-independent queries. `worker/realms/` owns the normalized Host and Client runtime backends. `worker/cdp/` owns HTTP discovery, DevTools sessions, Chrome method dispatch, domain enable state, and every connection-local Chrome identifier. + +The Worker remains the sole Chrome CDP wire and state owner. Client code simulates shared backend operations, not the CDP wire. Host code delegates supported backend operations to Node inspector, but Node protocol identifiers are translated inside the Worker Host realm before common domain projection. + +## Data and identifier ownership + +`shared/cordis/` contains the CDP-independent semantic model, immutable snapshots, collection and observation, realm-local object registration, projections, and reader interfaces. `model.ts` contains no transport handles or CDP identifiers. `snapshot.ts` may carry a realm-local opaque object reference because a live object query needs that route, but consumers can project it away. + +`shared/network/` contains fetch and network observations, captured body representation, and header normalization. These records describe observed activity and do not contain CDP request ids or domain enable state. + +`shared/cdp/` contains normalized backend interfaces and values for realm capabilities, Runtime, Console, Debugger, Sources, Profiler, HeapProfiler, and typed unsupported results. Backend handles in these interfaces are opaque and realm-owned. They are not Chrome `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, or `CallFrameId` values. + +`shared/bridge/` contains the versioned internal carrier: source and generation identifiers, envelopes, codecs, validation, bounded publication, RPC correlation, dispatch interfaces, and domain-specific message unions. Its message modules may transport Cordis snapshots, network observations, Console events, Runtime operations, source reads, debugger operations, and semantic queries without turning those values into CDP messages. + +`worker/cdp/ids.ts` is the only owner of Chrome connection-local identifiers such as `RemoteObjectId`, `ExecutionContextId`, `ScriptId`, `NodeId`, and `CallFrameId`. Worker domain sessions allocate and release them and map them to realm backend handles or inspection records. Source, generation, sequence, request, Cordis Fiber uid, realm object reference, backend handle, and Chrome id remain distinct types because their owners and lifetimes differ. + +## Dependency rules + +The domain modules `shared/cordis/`, `shared/network/`, and `shared/cdp/` do not import `shared/bridge/` or any execution-specific directory. `shared/bridge/` may import those domain types when defining internal messages. No module under `shared/` imports Node-only or browser-only APIs. + +Top-level `client/` and `host/` import `shared/` but never each other or `worker/`. Equivalent roles use equivalent shared interfaces. Environment-specific transport and engine behavior stays in the mirrored implementation file rather than entering a shared conditional implementation. + +`worker/realms/` and `worker/inspection/` import shared interfaces but do not import `worker/cdp/`; normalized backend results and stored observations cannot contain Chrome connection state. `worker/cdp/` may consume realm and inspection interfaces to project CDP. `worker/bridge/` routes shared messages and invokes Worker services without becoming an owner of Cordis, network, Runtime, or Chrome state. + +The package remains one `@deepseek-ai/dsh-experimental-inspector` package with explicit Client and Host compiler faces. Directory separation is an execution and dependency rule, not a package split. + +## Migration order + +First, the current Cordis implementation and its semantic types move into `shared/cordis/`. The current protocol directory then separates into `shared/bridge/`, `shared/cdp/`, and `shared/network/` while preserving validated frame discriminants and limits. + +Next, top-level Client and Host files move into exact mirrored paths. Missing support is represented explicitly so the mirrors remain complete. Worker Client and Node backends then move to mirrored `worker/realms/client/` and `worker/realms/host/` directories, with Node renamed to Host at the architectural interface. + +Finally, source transport and routing move under `worker/bridge/`, Cordis, network, and query repositories under `worker/inspection/`, and Chrome endpoint and domains under `worker/cdp/`. Imports, explicit compiler-face file lists, package entries, and focused tests change with each owning layer. The final tree has no generic top-level `protocol/` or `cordis/` directory and no generic `worker/backends/` directory. + +## Alternatives considered + +**Organize every file by feature domain.** Rejected because a Runtime or Cordis feature spans three environments with different available APIs. Feature-only paths conceal execution constraints and make accidental browser-to-Node imports difficult to review. + +**Put Worker Client and Host adapters in top-level `client/` and `host/`.** Rejected because those adapters execute in the Worker and own different resources from page and Node-main-thread producers. A directory name must answer where code runs before it answers which remote realm it represents. + +**Allow Client and Host trees to contain only currently supported files.** Rejected because asymmetric layout obscures missing capability decisions and lets equivalent routing roles acquire unrelated interfaces. Explicit unsupported implementations keep evolution exhaustive without pretending support exists. + +**Keep one shared protocol directory.** Rejected because internal carrier identities, Cordis semantic data, normalized Runtime values, and Chrome wire identifiers have different consumers and lifetimes. A single directory encourages domain models to depend on transport and CDP presentation. + +**Split Client, Host, protocol, and Worker into separate packages.** Rejected for the experimental phase. The deployment unit remains one Client/Host Cordis plugin, and package boundaries would add build and release coordination without improving the required execution separation. + +## Acceptance criteria + +- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root. +- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed. +- Cordis and network readers can be used without importing debugger, source, transport, or CDP session modules. +- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids. +- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages. +- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state. +- Existing Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior remain covered after the move. +- Compiler faces, import checks, and a structural test reject environment leaks and Client/Host mirror drift. + +## Risks + +Exact mirroring adds small adapter files for unsupported capabilities. Those files are intentional compatibility points between implementations, but they must stay thin and must not manufacture fake behavior. + +Moving types without changing behavior can still expose hidden dependency cycles, especially where Runtime object annotation reaches Cordis repositories. The dependency rules require inversion through shared interfaces rather than a temporary import from a lower-level module. + +`shared/cdp/` can become a second copy of the Chrome protocol if normalized types are added indiscriminately. A shared type belongs there only when both realm implementations or a common Worker projector consume it; Chrome session bookkeeping and wire-only fields remain under `worker/cdp/`. + +The migration may temporarily leave the package uncompilable between local move steps. The completed change must restore both compiler faces and behavior tests before review. diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md b/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md new file mode 100644 index 0000000000..be4dd947fd --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md @@ -0,0 +1,104 @@ +# Agent Note: Inspector 执行环境与协议平面 + +Status: proposed + +[English](2026-08-26-inspector-execution-realms-and-protocol-planes.md) | 中文 + +## Problem + +Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。当前源码树把执行归属与功能名称混在一起:浏览器和 Host producer 使用互不对应的目录结构,代表 Client 与 Host 的 Worker 代码位于笼统的 backend 目录,而一个 protocol 目录同时包含 transport frame、Cordis 数据、network observation 与面向 CDP 的 Runtime value。因此,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。 + +这种含糊会带来风险,因为 Host 与 Client 的支持能力有意不同,但架构必须保持可比较。Host Runtime 与 Debugger 委托 Node inspector protocol;Client Runtime 与 Console 通过内部 bridge 模拟同一套 backend 语义。如果两边的文件、接口与 unsupported operation 在结构上分叉,每增加一种协议方法都容易产生第二套路由模型。同样,只需要 Cordis 运行时树的消费方不应继承 debugger activation、Chrome 连接状态或 CDP 标识符。 + +现有的[跨 realm CDP Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)继续负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](../../implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md)继续负责 Cordis 树语义、对象路由与 DOM projection。本提案只负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。 + +## Proposal + +顶层源码目录将标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。 + +仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。 + +```text +src/ + shared/ environment-independent data and interfaces + client/ browser Client producer and adapters + host/ Host Node-main-thread producer and adapters + worker/ Worker transport, repositories, realm backends, and CDP endpoint +``` + +`client/` 与 `host/` 将拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。 + +Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口,规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。 + +## Execution ownership + +`client/` 负责 page realm observation、Client object handle、浏览器求值、浏览器 Console interception、Client source publication 以及到 Worker 的直接鉴权 bridge。它可以使用浏览器 API,但不能导入 Node 或 Worker 实现模块。 + +`host/` 负责 Node 主线程上的 Cordis plugin composition、Worker 启动与 dispose、Host object observation、fetch capture、Node inspector notification forwarding,以及 Worker bridge 的 Host 一侧。它可以使用 Node API,但不构造 Chrome CDP response。 + +`worker/bridge/` 负责 source admission、transport endpoint、connection generation、frame dispatch、correlation,以及 source producer 与 Worker consumer 之间的路由。`worker/inspection/` 负责保留的 Cordis 与 network observation,以及不依赖 transport 的 query。`worker/realms/` 负责规范化的 Host 与 Client runtime backend。`worker/cdp/` 负责 HTTP discovery、DevTools session、Chrome method dispatch、domain enable 状态与所有连接局部的 Chrome 标识符。 + +Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模拟共享 backend operation,而不是模拟 CDP wire。Host 代码把支持的 backend operation 委托给 Node inspector,但 Node protocol 标识符在 Worker Host realm 内转换后才进入公共 domain projection。 + +## Data and identifier ownership + +`shared/cordis/` 包含与 CDP 无关的语义模型、不可变 snapshot、collection 与 observation、realm-local object registration、projection 与 reader interface。`model.ts` 不包含 transport handle 或 CDP 标识符。`snapshot.ts` 可以携带 realm-local opaque object reference,因为实时对象查询需要该路由信息,但消费方可以在 projection 中移除它。 + +`shared/network/` 包含 fetch 与 network observation、采集 body 表示及 header normalization。这些记录描述已观测活动,不包含 CDP request id 或 domain enable 状态。 + +`shared/cdp/` 包含 realm capability、Runtime、Console、Debugger、Sources、Profiler、HeapProfiler 的规范化 backend 接口和值,以及类型化 unsupported 结果。这些接口中的 backend handle 是不透明且由 realm 持有的。它们不是 Chrome `RemoteObjectId`、`ExecutionContextId`、`ScriptId` 或 `CallFrameId`。 + +`shared/bridge/` 包含带版本的内部 carrier:source 与 generation 标识符、envelope、codec、validation、有限 publication、RPC correlation、dispatch interface 及分领域的 message union。其 message 模块可以传输 Cordis snapshot、network observation、Console event、Runtime operation、source read、debugger operation 与语义 query,但不会把这些值转换成 CDP message。 + +`worker/cdp/ids.ts` 是 Chrome 连接局部标识符的唯一 owner,包括 `RemoteObjectId`、`ExecutionContextId`、`ScriptId`、`NodeId` 与 `CallFrameId`。Worker domain session 分配并释放这些 id,把它们映射到 realm backend handle 或 inspection record。source、generation、sequence、request、Cordis Fiber uid、realm object reference、backend handle 与 Chrome id 必须保持为不同类型,因为它们的 owner 和生命周期不同。 + +## Dependency rules + +领域模块 `shared/cordis/`、`shared/network/` 与 `shared/cdp/` 不导入 `shared/bridge/` 或任何执行环境专属目录。`shared/bridge/` 在定义内部 message 时可以导入这些领域类型。`shared/` 下的任何模块都不导入 Node-only 或 browser-only API。 + +顶层 `client/` 与 `host/` 可以导入 `shared/`,但不能互相导入,也不能导入 `worker/`。等价角色使用等价的共享接口。环境专属 transport 与 engine 行为保留在对应镜像实现文件中,不进入带条件分支的共享实现。 + +`worker/realms/` 与 `worker/inspection/` 可以导入共享接口,但不导入 `worker/cdp/`;规范化 backend result 和已存 observation 不能包含 Chrome connection state。`worker/cdp/` 可以消费 realm 与 inspection interface 来生成 CDP projection。`worker/bridge/` 路由共享 message 并调用 Worker service,但不成为 Cordis、network、Runtime 或 Chrome 状态的 owner。 + +本能力继续保留在同一个 `@deepseek-ai/dsh-experimental-inspector` 包中,并使用显式 Client 与 Host compiler face。目录分隔是执行与依赖规则,不是拆包方案。 + +## Migration order + +首先把现有 Cordis 实现及其语义类型移动到 `shared/cordis/`。随后把当前 protocol 目录拆成 `shared/bridge/`、`shared/cdp/` 与 `shared/network/`,同时保留已验证的 frame discriminant 与限制。 + +接下来把顶层 Client 与 Host 文件移动到严格镜像的路径。缺失支持使用显式表示,使镜像保持完整。然后把 Worker Client 与 Node backend 移动到镜像的 `worker/realms/client/` 和 `worker/realms/host/` 目录,并在架构接口上把 Node 命名统一为 Host。 + +最后把 source transport 与 routing 移到 `worker/bridge/`,Cordis、network 与 query repository 移到 `worker/inspection/`,Chrome endpoint 与 domain 移到 `worker/cdp/`。imports、显式 compiler-face file list、package entry 与聚焦测试随所属层一起修改。最终源码树不保留笼统的顶层 `protocol/`、`cordis/` 或 `worker/backends/` 目录。 + +## Alternatives considered + +**所有文件都按功能领域组织。** 拒绝,因为一个 Runtime 或 Cordis 功能会跨越三个可用 API 不同的环境。只有功能信息的路径会隐藏执行限制,也让 browser 到 Node 的意外导入难以审查。 + +**把 Worker Client 与 Host adapter 放入顶层 `client/` 和 `host/`。** 拒绝,因为这些 adapter 实际运行在 Worker 中,持有的资源也不同于 page 与 Node 主线程 producer。目录名应先回答代码在哪里运行,再回答它代表哪个远端 realm。 + +**Client 与 Host 目录只保留当前支持的文件。** 拒绝,因为不对称目录会隐藏缺失能力决策,并允许等价路由角色形成无关接口。显式 unsupported 实现既保证穷尽演进,也不虚构已支持行为。 + +**保留一个共享 protocol 目录。** 拒绝,因为内部 carrier identity、Cordis 语义数据、规范化 Runtime value 与 Chrome wire identifier 的消费方和生命周期不同。单一目录会诱导领域模型依赖 transport 和 CDP presentation。 + +**把 Client、Host、protocol 与 Worker 拆成多个包。** 实验阶段拒绝。部署单元仍是一个 Client/Host Cordis plugin;包边界会增加构建和发布协作,却不能改善所需的执行环境分隔。 + +## Acceptance criteria + +- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。 +- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。 +- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。 +- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。 +- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。 +- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。 +- 移动后继续覆盖现有 Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为。 +- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。 + +## Risks + +严格镜像会为不支持的能力增加小型 adapter 文件。这些文件是两个实现之间有意保留的兼容点,但必须保持轻薄,也不能制造虚假行为。 + +即使只移动类型而不改变行为,也可能暴露隐藏的依赖环,尤其是 Runtime object annotation 访问 Cordis repository 的位置。依赖规则要求通过共享接口反转依赖,不能临时从较低层模块反向导入。 + +如果不加约束地添加规范化类型,`shared/cdp/` 可能变成第二份 Chrome protocol。只有两个 realm 实现或公共 Worker projector 会消费的类型才属于这里;Chrome session bookkeeping 与 wire-only field 保留在 `worker/cdp/`。 + +本地迁移步骤之间可能暂时无法编译。完整变更必须在 review 前恢复两个 compiler face 与行为测试。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 5d1d98a404..4a4d691ac6 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 1e7e6e39d307a9e72b5d57420bde99f51063f64d -capability-seams.zh.md: e33a1e6da7f71838c48b961f93389ba1a089f57f +capability-seams.md: 3886ca582ea934c51fc20dfec01fd9f2af829597 +capability-seams.zh.md: a79b93bb8d6fff36e0828dbba8f7e20b885bcbfe diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1e7e6e39d3..3886ca582e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -177,6 +177,8 @@ flowchart LR svc_agentTeams["ctx.agentTeams
    Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_inspector["inspector"] + svc_inspector["ctx.inspector
    Cross-realm runtime inspection"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
    Background job registry"] pkg_jobs_local["jobs-local"] @@ -256,6 +258,7 @@ flowchart LR pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker pkg_host_webserver --> svc_webServer + pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants pkg_jobs --> svc_jobs pkg_jobs_local --> svc_jobs @@ -513,6 +516,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | +| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index e33a1e6da7..a79b93bb8d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -179,6 +179,8 @@ flowchart LR svc_agentTeams["ctx.agentTeams
    Agent Teams coordination domain"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_inspector["inspector"] + svc_inspector["ctx.inspector
    Cross-realm runtime inspection"] pkg_jobs["jobs"] svc_jobs["ctx.jobs
    Background job registry"] pkg_jobs_local["jobs-local"] @@ -258,6 +260,7 @@ flowchart LR pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker pkg_host_webserver --> svc_webServer + pkg_inspector --> svc_inspector pkg_invariants --> svc_invariants pkg_jobs --> svc_jobs pkg_jobs_local --> svc_jobs @@ -515,6 +518,7 @@ flowchart LR | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | +| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | | `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 750dab17ed..638000d0ef 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: ab16221ff6c13768c9b0fb6a8189e30565dcc289 -config-catalog.zh.md: 8c9d956ad3ad5f672f73e5b4dd02aaed938c8667 +config-catalog.md: 4331c0a5153f32f0e6af5b6ec6fd182ee9b335b4 +config-catalog.zh.md: 54f6ddde10053d422af2c5ebfd88a367597adc89 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ab16221ff6..4331c0a515 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -612,6 +612,74 @@ export interface Config { Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts) + + +## `@deepseek-ai/dsh-experimental-inspector` + +Requires: `webServer` + +```ts config-catalog +/** Host plugin configuration. Fetch capture is enabled by default. */ +export interface Config extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +/** User-facing Host options; every memory and lifecycle bound is configurable. */ +export interface InspectorOptions { + /** Loopback address used by the Worker HTTP and WebSocket endpoint. */ + readonly host?: '127.0.0.1' + /** First port to bind; occupied ports advance until one is available. */ + readonly port?: number + /** Additional exact browser origins admitted to the Client ingest socket. */ + readonly clientOrigins?: readonly string[] + /** Whether to observe calls made through the current global fetch function. */ + readonly captureFetch?: boolean + /** Maximum request-body prefix retained for one fetch. */ + readonly maxRequestBodyBytes?: number + /** Maximum response-body prefix retained for one fetch. */ + readonly maxResponseBodyBytes?: number + /** Maximum raw bytes encoded into one body observation. */ + readonly maxBodyChunkBytes?: number + /** Maximum total request and response body bytes retained by the Worker. */ + readonly maxJournalBytes?: number + /** Maximum active and completed fetch requests retained by the Worker. */ + readonly maxRetainedRequests?: number + /** Maximum encoded bytes accepted in one source transport frame. */ + readonly maxSourceFrameBytes?: number + /** Maximum observation records accepted in one source batch. */ + readonly maxSourceRecordsPerFrame?: number + /** Maximum records waiting in one producer queue. */ + readonly maxQueuedRecords?: number + /** Maximum encoded bytes waiting in one producer queue. */ + readonly maxQueuedBytes?: number + /** Maximum time allowed for the Worker to become ready. */ + readonly startupTimeoutMs?: number + /** Grace period before a stopping Worker is terminated. */ + readonly stopTimeoutMs?: number + /** Initial upper bound for randomized Client reconnect delay. */ + readonly clientReconnectBaseMs?: number + /** Maximum upper bound for randomized Client reconnect delay. */ + readonly clientReconnectMaxMs?: number + /** Deadline for one Worker-to-Client Runtime or Sources request. */ + readonly clientRuntimeTimeoutMs?: number + /** Deadline for one non-CDP semantic query. */ + readonly queryTimeoutMs?: number + /** Maximum live object handles retained per Client Runtime session. */ + readonly maxClientRuntimeObjects?: number + /** Maximum descriptors returned by one Client property request. */ + readonly maxClientRuntimeProperties?: number + /** Maximum encoded bytes read for one Client script or source map. */ + readonly maxClientSourceBytes?: number + /** Maximum Context and Fiber nodes retained in one realm snapshot. */ + readonly maxCordisNodes?: number + /** Disconnected Cordis snapshots retained after their live realm closes. */ + readonly maxDisconnectedCordisTrees?: number +} +``` + +Source: [`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts) + ## `@deepseek-ai/dsh-experimental-tool-agent-team` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8c9d956ad3..54f6ddde10 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -614,6 +614,74 @@ export interface Config { 来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts) + + +## `@deepseek-ai/dsh-experimental-inspector` + +需要:`webServer` + +```ts config-catalog +/** Host plugin configuration. Fetch capture is enabled by default. */ +export interface Config extends Omit { + /** Browser origins allowed to open the Client ingest WebSocket. */ + clientOrigins?: string[] +} + +/** User-facing Host options; every memory and lifecycle bound is configurable. */ +export interface InspectorOptions { + /** Loopback address used by the Worker HTTP and WebSocket endpoint. */ + readonly host?: '127.0.0.1' + /** First port to bind; occupied ports advance until one is available. */ + readonly port?: number + /** Additional exact browser origins admitted to the Client ingest socket. */ + readonly clientOrigins?: readonly string[] + /** Whether to observe calls made through the current global fetch function. */ + readonly captureFetch?: boolean + /** Maximum request-body prefix retained for one fetch. */ + readonly maxRequestBodyBytes?: number + /** Maximum response-body prefix retained for one fetch. */ + readonly maxResponseBodyBytes?: number + /** Maximum raw bytes encoded into one body observation. */ + readonly maxBodyChunkBytes?: number + /** Maximum total request and response body bytes retained by the Worker. */ + readonly maxJournalBytes?: number + /** Maximum active and completed fetch requests retained by the Worker. */ + readonly maxRetainedRequests?: number + /** Maximum encoded bytes accepted in one source transport frame. */ + readonly maxSourceFrameBytes?: number + /** Maximum observation records accepted in one source batch. */ + readonly maxSourceRecordsPerFrame?: number + /** Maximum records waiting in one producer queue. */ + readonly maxQueuedRecords?: number + /** Maximum encoded bytes waiting in one producer queue. */ + readonly maxQueuedBytes?: number + /** Maximum time allowed for the Worker to become ready. */ + readonly startupTimeoutMs?: number + /** Grace period before a stopping Worker is terminated. */ + readonly stopTimeoutMs?: number + /** Initial upper bound for randomized Client reconnect delay. */ + readonly clientReconnectBaseMs?: number + /** Maximum upper bound for randomized Client reconnect delay. */ + readonly clientReconnectMaxMs?: number + /** Deadline for one Worker-to-Client Runtime or Sources request. */ + readonly clientRuntimeTimeoutMs?: number + /** Deadline for one non-CDP semantic query. */ + readonly queryTimeoutMs?: number + /** Maximum live object handles retained per Client Runtime session. */ + readonly maxClientRuntimeObjects?: number + /** Maximum descriptors returned by one Client property request. */ + readonly maxClientRuntimeProperties?: number + /** Maximum encoded bytes read for one Client script or source map. */ + readonly maxClientSourceBytes?: number + /** Maximum Context and Fiber nodes retained in one realm snapshot. */ + readonly maxCordisNodes?: number + /** Disconnected Cordis snapshots retained after their live realm closes. */ + readonly maxDisconnectedCordisTrees?: number +} +``` + +来源:[`packages/experimental/inspector/src/index.ts:66`](../packages/experimental/inspector/src/index.ts) + ## `@deepseek-ai/dsh-experimental-tool-agent-team` diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 3cdec59fc3..e5cf853414 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 9bd351a204ceb6ab262d2f2b9a0276c44be20cc5 -event-producer-consumer.zh.md: 453d22632dbcc73edec67a44759f1de42490be88 +event-producer-consumer.md: e849cb84265c0781e4a8680d0bb247e9955b5c2e +event-producer-consumer.zh.md: b5835c56b26f3a75fd792d3a71c3ab2dc688ea42 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 9bd351a204..e849cb8426 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -78,8 +78,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event string | Dispatchers | Listeners | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | +| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 453d22632d..b5835c56b2 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -67,7 +67,7 @@ | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | -| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `modules` | +| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -80,8 +80,8 @@ | 事件字符串 | 派发方 | 监听方 | | --- | --- | --- | | `internal/dispatch` | - | `agent-team`, [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`terminal-bash`](../packages/terminal/terminal-bash), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`webhook`](../packages/webhook/webhook), [`workflow`](../packages/workflow/workflow) | -| `internal/plugin` | - | `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | +| `internal/plugin` | - | `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), `modules`, `webserver` | | `internal/service` | - | [`agent-presets`](../packages/preset/agent-presets), `gateway` | -| `internal/status` | - | [`agent`](../packages/core/agent) | +| `internal/status` | - | [`agent`](../packages/core/agent), `inspector` | 维护模式:生成内容。Cordis 事件声明及生产方/监听方的关系边由仓库的 TypeScript Program 解析。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 5a1b662432..1fda2fbe2d 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e6013b2915121444f9f1e5ccc172190b8fccc650 -module-graph.zh.md: 6b491805dbbf7733cbda881552a2b8319d81ecc4 +module-graph.md: 0708aca1672546d28e956109f0fd7e5c255f4613 +module-graph.zh.md: ee0a7e54bef93247c1056d8736868c3de8e813d3 diff --git a/docs/module-graph.md b/docs/module-graph.md index e6013b2915..0708aca167 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,7 +108,6 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] - pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] @@ -209,6 +208,7 @@ flowchart TD pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_experimental_inspector["experimental-inspector"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -411,6 +411,8 @@ flowchart TD pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -437,6 +439,13 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment + pkg_experimental_inspector --> pkg_client_modules + pkg_experimental_inspector --> pkg_host_webserver + pkg_experimental_inspector --> pkg_invariants + pkg_settings_file --> pkg_atomic_write + pkg_settings_file --> pkg_home_paths + pkg_settings_file --> pkg_invariants + pkg_settings_file --> pkg_settings pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -499,9 +508,6 @@ flowchart TD pkg_session_persistence --> pkg_timeout pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session - pkg_settings --> pkg_brand - pkg_settings --> pkg_invariants - pkg_settings --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_llm_retry --> pkg_agent @@ -535,11 +541,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants - pkg_api_settings_controller --> pkg_session - pkg_api_settings_controller --> pkg_settings - pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_file_reference --> pkg_typert_protocol @@ -608,10 +609,6 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_settings_file --> pkg_atomic_write - pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants - pkg_settings_file --> pkg_settings pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings @@ -1095,7 +1092,6 @@ flowchart TD pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands @@ -1111,10 +1107,7 @@ flowchart TD pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session - pkg_session_reference --> pkg_session_projection - pkg_session_reference --> pkg_session_projection_cache pkg_session_reference --> pkg_session_query - pkg_session_reference --> pkg_session_title pkg_session_reference --> pkg_typert_protocol pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_instructions @@ -1303,7 +1296,6 @@ flowchart TD pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner @@ -1547,15 +1539,12 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes - pkg_client_ui_reference --> pkg_api_session_controller - pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol - pkg_client_ui_reference --> pkg_util_workspace_path pkg_client_ui_subagent --> pkg_api_session_controller pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale @@ -1631,6 +1620,7 @@ flowchart TD pkg_client_ui_model_selection --> pkg_typert_protocol pkg_client_ui_permission_presets --> pkg_api_remotes pkg_client_ui_permission_presets --> pkg_api_session_controller + pkg_client_ui_permission_presets --> pkg_client_connection pkg_client_ui_permission_presets --> pkg_client_locale pkg_client_ui_permission_presets --> pkg_client_ui_commands pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger @@ -1742,6 +1732,7 @@ flowchart TD | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1751,6 +1742,8 @@ flowchart TD | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1770,7 +1763,6 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | @@ -1778,7 +1770,6 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | @@ -1794,7 +1785,6 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1873,9 +1863,9 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | @@ -1901,7 +1891,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1928,7 +1918,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1937,7 +1927,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 6b491805db..ee0a7e54be 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -110,7 +110,6 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] - pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] @@ -211,6 +210,7 @@ flowchart TD pkg_experimental_agent_team_profile["experimental-agent-team-profile"] pkg_experimental_agent_team_web_profile["experimental-agent-team-web-profile"] pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] + pkg_experimental_inspector["experimental-inspector"] pkg_experimental_tool_agent_team["experimental-tool-agent-team"] pkg_experimental_webworker_packer["experimental-webworker-packer"] pkg_experimental_webworker_runtime["experimental-webworker-runtime"] @@ -413,6 +413,8 @@ flowchart TD pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -439,6 +441,13 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment + pkg_experimental_inspector --> pkg_client_modules + pkg_experimental_inspector --> pkg_host_webserver + pkg_experimental_inspector --> pkg_invariants + pkg_settings_file --> pkg_atomic_write + pkg_settings_file --> pkg_home_paths + pkg_settings_file --> pkg_invariants + pkg_settings_file --> pkg_settings pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -501,9 +510,6 @@ flowchart TD pkg_session_persistence --> pkg_timeout pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session - pkg_settings --> pkg_brand - pkg_settings --> pkg_invariants - pkg_settings --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_llm_retry --> pkg_agent @@ -537,11 +543,6 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants - pkg_api_settings_controller --> pkg_session - pkg_api_settings_controller --> pkg_settings - pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_file_reference --> pkg_typert_protocol @@ -610,10 +611,6 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection - pkg_settings_file --> pkg_atomic_write - pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants - pkg_settings_file --> pkg_settings pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings @@ -1097,7 +1094,6 @@ flowchart TD pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands @@ -1113,10 +1109,7 @@ flowchart TD pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session - pkg_session_reference --> pkg_session_projection - pkg_session_reference --> pkg_session_projection_cache pkg_session_reference --> pkg_session_query - pkg_session_reference --> pkg_session_title pkg_session_reference --> pkg_typert_protocol pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_instructions @@ -1305,7 +1298,6 @@ flowchart TD pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner @@ -1549,15 +1541,12 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes - pkg_client_ui_reference --> pkg_api_session_controller - pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol - pkg_client_ui_reference --> pkg_util_workspace_path pkg_client_ui_subagent --> pkg_api_session_controller pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale @@ -1633,6 +1622,7 @@ flowchart TD pkg_client_ui_model_selection --> pkg_typert_protocol pkg_client_ui_permission_presets --> pkg_api_remotes pkg_client_ui_permission_presets --> pkg_api_session_controller + pkg_client_ui_permission_presets --> pkg_client_connection pkg_client_ui_permission_presets --> pkg_client_locale pkg_client_ui_permission_presets --> pkg_client_ui_commands pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger @@ -1696,7 +1686,7 @@ flowchart TD pkg_client_ui_cordis --> pkg_invariants ``` -| Package | Group | Depends on | +| 包 | 分组 | 依赖 | | --- | --- | --- | | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1744,6 +1734,7 @@ flowchart TD | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1753,6 +1744,8 @@ flowchart TD | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | +| [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1772,7 +1765,6 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | @@ -1780,7 +1772,6 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | @@ -1796,7 +1787,6 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | -| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1875,9 +1865,9 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | @@ -1903,7 +1893,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1930,7 +1920,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1939,7 +1929,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/subsystems/extensions.i18n.yaml b/docs/subsystems/extensions.i18n.yaml index e3a18d04d7..91f9f20574 100644 --- a/docs/subsystems/extensions.i18n.yaml +++ b/docs/subsystems/extensions.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/extensions.md -extensions.md: 0418afc7f1b6b6cd892deb618a4f346f6cde720d -extensions.zh.md: f2d86add9f1b62913fcc1b89abf1fc1a1d2a178f +extensions.md: 540f3c477b4e128b0c1062192185e6276c9e9263 +extensions.zh.md: ebfe7827484cea2cf8c6d77ca26796f7751d203a diff --git a/docs/subsystems/extensions.md b/docs/subsystems/extensions.md index 0418afc7f1..540f3c477b 100644 --- a/docs/subsystems/extensions.md +++ b/docs/subsystems/extensions.md @@ -256,6 +256,24 @@ Types: [Agent](core.md) Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts) + + +### `ctx.inspector` — `InspectorService` + +Shared Host/Client service façade over the realm's source publisher. + +```ts cordis-catalog +/** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ +publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +``` + +Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts) + ### `cordis/*` events diff --git a/docs/subsystems/extensions.zh.md b/docs/subsystems/extensions.zh.md index f2d86add9f..ebfe782748 100644 --- a/docs/subsystems/extensions.zh.md +++ b/docs/subsystems/extensions.zh.md @@ -256,6 +256,24 @@ Types: [Agent](core.zh.md) Source: [`packages/extensions/cordis-host-runner/src/index.ts`](../../packages/extensions/cordis-host-runner/src/index.ts) + + +### `ctx.inspector` — `InspectorService` + +Shared Host/Client service façade over the realm's source publisher. + +```ts cordis-catalog +/** + * Publish one JSON observation without waiting for Worker delivery. + * @param topic - Domain-owned topic name. + * @param payload - JSON value validated before it reaches the carrier. + * @param monotonicMs - Source-clock timestamp; defaults to `performance.now()`. + */ +publish(topic: string, payload: InspectorJsonValue, monotonicMs?: number): void +``` + +Source: [`packages/experimental/inspector/src/index.ts`](../../packages/experimental/inspector/src/index.ts) + ### `cordis/*` events diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 3636475c1a..b7e7236631 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/README.md -README.md: 2812388377611ebbed0f415d637bd14bffcee34c -README.zh.md: 3dcf13d9ef73b540fe4b8f3d7f6df2e623bca6f4 +README.md: 750f38a116681a4a57575e9a55a49c06e7b40108 +README.zh.md: 551ef051a57e2787cea080a3df26c98d2af68f7c diff --git a/packages/experimental/README.md b/packages/experimental/README.md index 2812388377..750f38a116 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The experimental group contains prototype capabilities that are not part of any official release: they run on the real harness, but their contracts can change and they carry no support promise. The group holds Agent Teams plus the browser-worker runtime and image packer used by preview deployments. Use these packages to try an unreleased capability; they carry no stability promise, and released products must not depend on them. +The experimental group contains prototype capabilities that are not part of any official release: they run on the real harness, but their contracts can change and they carry no support promise. The group holds Agent Teams, the cross-realm Inspector, and the browser-worker runtime and image packer used by preview deployments. Use these packages to try an unreleased capability; they carry no stability promise, and released products must not depend on them. ## Table of Contents @@ -28,6 +28,7 @@ The experimental group contains prototype capabilities that are not part of any | [`agent-team`](agent-team/README.md) | Named teammates with durable messages and a shared task board | `ctx.agentTeams` | | [`agent-team-web-profile`](agent-team-web-profile/README.md) | Explicit source-checkout Web layer for Agent Teams | — | | [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — | +| [`inspector`](inspector/README.md) | Cross-realm CDP hub for Host debugging, Client Runtime inspection, network capture, and Cordis trees | `ctx.inspector` | | [`tool-agent-team`](tool-agent-team/README.md) | Ten tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` | | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key | | [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key | diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 3dcf13d9ef..551ef051a5 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -实验组包含不属于任何正式发布的原型能力:它们运行在真实 harness 上,但约定可能变更,也不提供支持承诺。本组包含 Agent Teams,以及预览部署使用的浏览器 worker 运行时与镜像打包器。用这些包来尝试未发布的能力;它们没有稳定性承诺,已发布产品不得依赖它们。 +实验组包含不属于任何正式发布的原型能力:它们运行在真实 harness 上,但约定可能变更,也不提供支持承诺。本组包含 Agent Teams、跨 realm Inspector,以及预览部署使用的浏览器 worker 运行时与镜像打包器。用这些包来尝试未发布的能力;它们没有稳定性承诺,已发布产品不得依赖它们。 ## 目录 @@ -28,6 +28,7 @@ kind: "package-group" | [`agent-team`](agent-team/README.zh.md) | 具名 teammate,成员之间持久消息与共享任务板 | `ctx.agentTeams` | | [`agent-team-web-profile`](agent-team-web-profile/README.zh.md) | Agent Teams 的显式源码 checkout Web 层 | — | | [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — | +| [`inspector`](inspector/README.zh.md) | 用于 Host 调试、Client Runtime 检查、网络采集与 Cordis 树的跨 realm CDP hub | `ctx.inspector` | | [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的十个工具 | 按作用域注册工具到 `ctx.tools` | | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩 VFS 镜像 | 库与 CLI,不使用 ctx key | | [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key | diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml new file mode 100644 index 0000000000..f146394b7a --- /dev/null +++ b/packages/experimental/inspector/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md +README.md: dcebe9527de1b909fe1efc9f73977735ad374a95 +README.zh.md: 9ca43b0420f22969b6bd060bf9546eb094c4fbc0 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md new file mode 100644 index 0000000000..dcebe9527d --- /dev/null +++ b/packages/experimental/inspector/README.md @@ -0,0 +1,105 @@ +# @deepseek-ai/dsh-experimental-inspector + +English | [中文](README.zh.md) + +Experimental Client/Host Cordis plugin that exposes one Chrome DevTools Protocol target from a Node worker thread. The Host and browser Client publish versioned observations into the Worker; the Worker is the sole owner of CDP state and output. + +The package is private and excluded from releases. The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis also owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. + +## Runtime layout + +The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client plugin reads the injected `globalThis.__DSH_INSPECTOR__` bootstrap and opens a separate authenticated WebSocket directly to the Worker. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host Console evaluation, Sources, breakpoints, and resume remain available while Host JavaScript is paused. + +The source tree follows those execution environments: `client/` and `host/` provide mirrored adapter entry paths, `worker/` contains only Worker-thread orchestration and Chrome protocol state, and `shared/` contains environment-independent Cordis and network models, normalized realm backend interfaces, and the internal bridge protocol. Worker-side Client and Host adapters are mirrored under `worker/realms/`; a Client adapter in that directory still executes in the Worker. + +Host and Client producers send internal observation records rather than CDP messages. Records contain a source generation, sequence, source-clock timestamp, topic, and JSON payload. The Worker validates every process or network frame, owns source state and retention, and translates recognized topics to standard CDP domains. + +Client sources declare typed Runtime, Console, and read-only Sources capabilities. `Runtime.enable` publishes the real Host execution context and one synthetic context for every connected Client source. Selecting a Client context routes evaluation, property access, function calls, promise awaiting, and object release to that browser realm. Client Console arguments use the same session-local object table, while `Debugger.enable` publishes the built `lib/client.js` catalog and `Debugger.getScriptSource` reads bounded content chunks. Client-script breakpoints, step, and call frames remain unsupported; target-wide pause and resume control the Host debugger only. + +Both plugin faces run the same browser-safe Cordis collector. It converts reachable Context and Fiber objects into a versioned `CordisTreeSnapshot`; the Worker stores that CDP-independent representation and projects each Host or Client source into the Elements panel. + +## Configuration + +The Host plugin injects `webServer` and accepts these fields: + +| Field | Default | Meaning | +|---|---:|---| +| `host` | `127.0.0.1` | Worker endpoint bind address; only loopback is accepted | +| `port` | `9230` | First Worker endpoint port; occupied ports advance upward, while `0` requests an OS-assigned port | +| `clientOrigins` | `[]` | Additional exact browser origins accepted by `/ingest`; loopback origins remain accepted | +| `captureFetch` | `true` | Wrap `globalThis.fetch` and publish every later call | +| `maxRequestBodyBytes` | 8 MiB | Per-request captured request-body prefix | +| `maxResponseBodyBytes` | 32 MiB | Per-request captured response-body prefix | +| `maxBodyChunkBytes` | 48 KiB | Raw bytes carried by one body record before base64 encoding | +| `maxJournalBytes` | 256 MiB | Worker-retained request and response body bytes | +| `maxRetainedRequests` | `2000` | Active and completed requests retained by the Worker | +| `maxSourceFrameBytes` | 128 KiB | Encoded source-frame limit | +| `maxSourceRecordsPerFrame` | `128` | Records in one source batch | +| `maxQueuedRecords` | `2048` | Per-producer records waiting for transport | +| `maxQueuedBytes` | 16 MiB | Per-producer queued encoded bytes | +| `startupTimeoutMs` | 10 seconds | Worker readiness deadline | +| `stopTimeoutMs` | 5 seconds | Graceful Worker shutdown deadline before termination | +| `clientReconnectBaseMs` | 250 ms | First Client reconnect backoff cap | +| `clientReconnectMaxMs` | 5 seconds | Maximum Client reconnect backoff cap | +| `clientRuntimeTimeoutMs` | 30 seconds | Deadline for one Worker-to-Client Runtime or Sources command | +| `queryTimeoutMs` | 10 seconds | Deadline for one non-CDP semantic query | +| `maxClientRuntimeObjects` | `10000` | Live Client object handles retained per DevTools connection | +| `maxClientRuntimeProperties` | `2000` | Property descriptors returned by one Client object inspection | +| `maxClientSourceBytes` | 8 MiB | Maximum encoded bytes read from one Client script or source map | +| `maxCordisNodes` | `2048` | Context and Fiber nodes admitted from one realm snapshot before truncation | +| `maxDisconnectedCordisTrees` | `8` | Last disconnected realm trees retained as non-live snapshots | + +The Host logs a `devtools://` URL after the Worker listens. The same Worker serves `/json`, `/json/list`, `/json/version`, the target WebSocket under `/devtools/page/`, and the Client source at `/ingest`. + +## Observation API + +Both plugin faces provide the same service: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { InspectorJsonValue } from '@deepseek-ai/dsh-experimental-inspector' + +declare const ctx: Context +declare const topic: string +declare const jsonPayload: InspectorJsonValue + +ctx.inspector.publish(topic, jsonPayload) +await ctx.inspector.cordis.getTree() +``` + +Publishing validates lossless JSON and schedules delivery without waiting for the Worker. Each source has a bounded queue. Overflow is reported as a sequence gap and never delays the observed application operation. `cordis.getTree()` reads the Worker's latest detached semantic snapshot without creating a CDP session or enabling Runtime, Debugger, or Sources. + +## Cordis tree inspection + +The Elements document has fixed `` and `` containers. `` contains the Host root Context; `` contains one `` per Client source, and each `` contains that realm's root Context. The Cordis root Fiber is omitted. Every other Fiber is a child of `fiber.parent`, owns exactly one Context child for `fiber.ctx`, and carries only `uid=""`; Context elements have no attributes. Context-only `extend()`, `isolate()`, and `intercept()` layers remain direct Context descendants. + +Host and Client publish the same nested `CordisTreeSnapshot` type. Context and Fiber nodes carry opaque object handles for realm-local object lookup; Fiber nodes additionally carry Cordis `uid`. The Worker composes those realm snapshots into one `{ host, clients }` inspection tree. It assigns `BackendNodeId` values per source generation; each DevTools connection assigns its own `NodeId` values; `DOM.resolveNode` asks the owning Host or Client Runtime for a connection-local `RemoteObjectId`. `DOM.requestNode` maps that object id back to the same Elements node. `ctx.inspector.cordis.getTree()` and `DSHInspector.getCordisTree` read the detached consumer-neutral tree without routing handles or CDP ids. + +When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately. + +## Host fetch capture + +Fetch capture is on by default and records the complete URL, all request and response headers, request body, response body, status, timing, errors, and cancellation. It does not redact credentials, cookies, query values, or payloads. Body capture reads clones; the caller receives the original Response as soon as the original fetch resolves. + +The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer. + +## Security + +The CDP target grants arbitrary code execution in both Host and connected Client realms through `Runtime.evaluate`; Host Debugger operations provide additional control. Full fetch capture includes secrets. The Worker therefore accepts only a `127.0.0.1` bind address. Client ingest additionally requires a random WebSocket subprotocol token injected by the Host and rejects non-loopback origins unless explicitly configured. The CDP socket itself has no token; loopback binding is its only access control. + +## Model Experience + +None, as this developer-only inspector observes runtime activity without changing model requests. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Client active debugging is unsupported** — Console events, Runtime evaluation, RemoteObject access, and read-only `lib/client.js` Sources work. Client-script debugger requests return explicit unsupported errors; target-wide pause and resume control the Host only. +- **Client Sources expose the Inspector bundle only** — other page scripts are not cataloged by this package. +- **Client evaluation uses page JavaScript** — page Content Security Policy can block dynamic evaluation, and the synthetic context does not provide DevTools command-line helpers or native REPL declaration semantics. +- **Fetch interception covers `globalThis.fetch`** — direct Undici APIs and fetch references retained before activation are not observed. +- **Body cloning has cost** — full capture tees request and response streams up to the configured limits and can increase memory and I/O pressure. The retained-body limit does not include buffering inside the stream tee, including an oversized source chunk or data queued for a slower application reader. +- **No automatic Worker restart** — an unexpected Worker exit fails the current Inspector instance; lifecycle recovery belongs to a later change. diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md new file mode 100644 index 0000000000..9ca43b0420 --- /dev/null +++ b/packages/experimental/inspector/README.zh.md @@ -0,0 +1,105 @@ +# @deepseek-ai/dsh-experimental-inspector + +[English](README.md) | 中文 + +实验性 Client/Host 双面 Cordis 插件,在 Node worker thread 中提供一个 Chrome DevTools Protocol target。Host 与浏览器 Client 向 Worker 发布带版本的观测记录;Worker 独占 CDP 状态与输出。 + +本包为私有包,不进入正式发布。Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证 snapshot。Cordis 还负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和资源释放。 + +## 运行时布局 + +Host 插件启动 Worker 并连接专用 `MessagePort`。Client 插件读取注入的 `globalThis.__DSH_INSPECTOR__` bootstrap,直接向 Worker 打开一条独立、带鉴权的 WebSocket。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时,Host Console 求值、Sources、断点和 resume 仍然可用。 + +源码树遵循这些执行环境:`client/` 与 `host/` 提供镜像的 adapter entry path,`worker/` 只包含 Worker thread orchestration 与 Chrome protocol 状态,`shared/` 包含与环境无关的 Cordis 和 network model、规范化 realm backend interface 及内部 bridge protocol。Worker 侧 Client 与 Host adapter 镜像放在 `worker/realms/` 下;其中的 Client adapter 仍然在 Worker 中执行。 + +Host 与 Client producer 发送内部观测记录,不发送 CDP 消息。记录包含 source generation、sequence、source 时钟时间、topic 和 JSON payload。Worker 验证每个进程或网络帧,独占 source 状态与保留历史,并把已识别 topic 转换成标准 CDP domain。 + +Client source 声明类型化 Runtime、Console 和只读 Sources 能力。`Runtime.enable` 发布真实 Host execution context,并为每个已连接的 Client source 发布一个 synthetic context。选择 Client context 后,求值、属性读取、函数调用、Promise await 和对象释放都会路由到该浏览器 realm。Client Console argument 使用同一份 session-local object table;`Debugger.enable` 发布构建后的 `lib/client.js` catalog,`Debugger.getScriptSource` 读取有界 content chunk。Client script 断点、step 和 call frame 仍不支持;target-wide pause 与 resume 只控制 Host debugger。 + +两个插件面运行同一份浏览器安全 Cordis collector。它把可达 Context 与 Fiber 对象转换成有版本的 `CordisTreeSnapshot`;Worker 存储这份与 CDP 无关的表示,并把每个 Host 或 Client source 投影到 Elements 面板。 + +## 配置 + +Host 插件注入 `webServer`,接受以下字段: + +| 字段 | 默认值 | 含义 | +|---|---:|---| +| `host` | `127.0.0.1` | Worker endpoint 监听地址;只接受 loopback | +| `port` | `9230` | Worker endpoint 起始端口;端口占用时向上递增,`0` 表示由操作系统分配 | +| `clientOrigins` | `[]` | `/ingest` 额外接受的精确浏览器 origin;loopback origin 始终允许 | +| `captureFetch` | `true` | 包装 `globalThis.fetch` 并发布之后的每次调用 | +| `maxRequestBodyBytes` | 8 MiB | 每次请求保留的 request body 前缀 | +| `maxResponseBodyBytes` | 32 MiB | 每次请求保留的 response body 前缀 | +| `maxBodyChunkBytes` | 48 KiB | base64 编码前一条 body 记录携带的原始字节数 | +| `maxJournalBytes` | 256 MiB | Worker 保留的请求与响应 body 总字节数 | +| `maxRetainedRequests` | `2000` | Worker 保留的进行中与已完成请求总数 | +| `maxSourceFrameBytes` | 128 KiB | 编码后的 source frame 上限 | +| `maxSourceRecordsPerFrame` | `128` | 每个 source batch 的记录数 | +| `maxQueuedRecords` | `2048` | 每个 producer 等待发送的记录数 | +| `maxQueuedBytes` | 16 MiB | 每个 producer 等待发送的编码字节数 | +| `startupTimeoutMs` | 10 秒 | Worker ready 截止时间 | +| `stopTimeoutMs` | 5 秒 | 强制终止前的 Worker 优雅关闭期限 | +| `clientReconnectBaseMs` | 250 ms | Client 首次重连退避上限 | +| `clientReconnectMaxMs` | 5 秒 | Client 最大重连退避上限 | +| `clientRuntimeTimeoutMs` | 30 秒 | 一次 Worker 到 Client Runtime 或 Sources 命令的截止时间 | +| `queryTimeoutMs` | 10 秒 | 一次非 CDP 语义查询的截止时间 | +| `maxClientRuntimeObjects` | `10000` | 每条 DevTools 连接保留的 Client 实时对象 handle 数 | +| `maxClientRuntimeProperties` | `2000` | 单次 Client 对象检查返回的属性描述符数 | +| `maxClientSourceBytes` | 8 MiB | 单个 Client script 或 source map 允许读取的最大编码字节数 | +| `maxCordisNodes` | `2048` | 一个 realm snapshot 截断前允许的 Context 与 Fiber 节点数 | +| `maxDisconnectedCordisTrees` | `8` | 作为非实时 snapshot 保留的最近断联 realm 树数量 | + +Worker 监听后,Host 会记录一个 `devtools://` URL。同一个 Worker 提供 `/json`、`/json/list`、`/json/version`、`/devtools/page/` target WebSocket 和 `/ingest` Client source。 + +## 观测 API + +两个插件面都提供同一个服务: + +```ts +import type { Context } from '@deepseek-ai/cordis' +import type { InspectorJsonValue } from '@deepseek-ai/dsh-experimental-inspector' + +declare const ctx: Context +declare const topic: string +declare const jsonPayload: InspectorJsonValue + +ctx.inspector.publish(topic, jsonPayload) +await ctx.inspector.cordis.getTree() +``` + +发布操作先验证无损 JSON,再调度发送,不等待 Worker。每个 source 的队列都有上限;溢出表现为 sequence gap,绝不延迟被观察的应用操作。`cordis.getTree()` 读取 Worker 最新的 detached semantic snapshot,不创建 CDP session,也不启用 Runtime、Debugger 或 Sources。 + +## Cordis tree inspection + +Elements document 包含固定的 `` 与 `` 容器。`` 包含 Host root Context;`` 为每个 Client source 包含一个 ``,每个 `` 再包含该 realm 的 root Context。Cordis root Fiber 不显示。其他 Fiber 都是 `fiber.parent` 的子节点,并包含唯一一个表示 `fiber.ctx` 的 Context 子节点;Fiber 只携带 `uid=""`,Context element 不携带 attribute。只有 Context 的 `extend()`、`isolate()` 与 `intercept()` 层仍然是直接 Context 后代。 + +Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Fiber 节点携带用于 realm-local 对象查询的不透明 object handle;Fiber 还携带 Cordis `uid`。Worker 把这些 realm snapshot 组合成一棵 `{ host, clients }` inspection tree。Worker 按 source generation 分配 `BackendNodeId`;每条 DevTools 连接分配自己的 `NodeId`;`DOM.resolveNode` 请求所属 Host 或 Client Runtime 生成连接本地 `RemoteObjectId`。`DOM.requestNode` 把该 object id 映射回同一个 Elements 节点。`ctx.inspector.cordis.getTree()` 与 `DSHInspector.getCordisTree` 读取不含 routing handle 或 CDP id 的 detached consumer-neutral tree。 + +Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。 + +## Host fetch 采集 + +fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、请求体、响应体、状态、时间、错误和取消。它不脱敏 credential、Cookie、query value 或 payload。body 采集读取 clone;原始 fetch resolve 后,调用方立即拿到原始 Response。 + +配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。 + +## 安全 + +CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中的任意代码执行能力,Host Debugger 操作还会提供额外控制,完整 fetch 采集也包含秘密。因此 Worker 只接受 `127.0.0.1` 监听地址。Client ingest 还要求 Host 注入的随机 WebSocket subprotocol token;除非配置明确允许,否则拒绝非 loopback origin。CDP socket 本身不携带 token,loopback 监听是它唯一的访问控制。 + +## 模型体验 + +无:这个仅供开发者使用的 Inspector 只观察运行时活动,不改变模型请求。 + +#### KV Cache 影响 + +无:本包既不组装也不发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **Client active debugging 不受支持**——Console event、Runtime 求值、RemoteObject 访问和只读 `lib/client.js` Sources 可用。Client script debugger request 返回明确的 unsupported error;target-wide pause 与 resume 只控制 Host。 +- **Client Sources 只暴露 Inspector bundle**——本包不收录页面中的其他 script。 +- **Client 求值使用页面 JavaScript**——页面 Content Security Policy 可能阻止动态求值;synthetic context 不提供 DevTools command-line helper 或原生 REPL 声明语义。 +- **fetch 拦截范围是 `globalThis.fetch`**——直接调用 Undici API,以及激活前保存的 fetch 引用不会被观察。 +- **body clone 有运行成本**——完整采集会 tee 请求与响应 stream,直至达到配置上限,可能增加内存与 I/O 压力。保留 body 的上限不包含 stream tee 内部的缓冲,包括来源提供的超大 chunk,或为读取较慢的应用分支排队的数据。 +- **不自动重启 Worker**——Worker 意外退出会使当前 Inspector 实例失败;生命周期恢复留待后续改动。 diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 00af95b674..25c60c5b52 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -549,6 +549,13 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['experimental-tool-agent-team', 'experimental-client-ui-agent-team'], note: 'Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution.', }, + { + key: 'inspector', + pkg: 'inspector', + title: 'Cross-realm runtime inspection', + mode: 'core', + note: 'Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API.', + }, { key: 'jobs', pkg: 'jobs', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 9e88355ec9..4ea84b36fe 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -66,6 +66,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/test-support/client-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers nothing model-facing.' }, 'packages/experimental/webworker-runtime': { kind: 'none', reason: 'Browser-side host runtime and Node-compatibility layer; the plugins it boots own every model-facing registration.' }, 'packages/experimental/webworker-packer': { kind: 'none', reason: 'Build-time image writer; its output reaches a model only through the tree the worker then boots.' }, + 'packages/experimental/inspector': { kind: 'none', reason: 'Developer diagnostics transport; it observes runtime activity without changing model requests.' }, 'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, From daf385875914251410d2885d0d457373ecf97d45 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:28:08 +0800 Subject: [PATCH 070/130] fix(inspector): restore client console and response bodies --- .../inspector/src/host/inspection/network.ts | 14 ++------ .../src/worker/realms/client/runtime.ts | 2 -- .../inspector/tests/client-browser.e2e.ts | 22 +++++++++++- .../tests/fetch-observer.host.spec.ts | 36 +++++++++++++++++-- 4 files changed, 57 insertions(+), 17 deletions(-) diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts index d73d979fa9..fcbdadb76d 100644 --- a/packages/experimental/inspector/src/host/inspection/network.ts +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -117,19 +117,9 @@ export function installFetchObserver( responseClone.body, options.maxResponseBodyBytes, options.maxChunkBytes, - AbortSignal.any([controller.signal, request.signal]), + controller.signal, (data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) }, ).then((outcome) => { - if (request.signal.aborted) { - publisher.publish('fetch/error', { - requestId, - message: request.signal.reason === undefined - ? 'AbortError: request aborted during response body capture' - : renderError(request.signal.reason), - canceled: true, - }) - return - } publisher.publish('fetch/end', { requestId, capturedBytes: outcome.capturedBytes, @@ -210,7 +200,7 @@ async function captureBody( } return { capturedBytes, truncated } } catch (error) { - return { capturedBytes, truncated, captureError: renderError(error) } + return { capturedBytes, truncated: true, captureError: renderError(error) } } finally { signal.removeEventListener('abort', abort) reader.releaseLock() diff --git a/packages/experimental/inspector/src/worker/realms/client/runtime.ts b/packages/experimental/inspector/src/worker/realms/client/runtime.ts index ef44975849..fd298bcb56 100644 --- a/packages/experimental/inspector/src/worker/realms/client/runtime.ts +++ b/packages/experimental/inspector/src/worker/realms/client/runtime.ts @@ -143,8 +143,6 @@ function assertClientEvaluationOptions(request: Parameters { const context = event.params?.context as Record | undefined return String(context?.name).startsWith('Client —') }) - const contextId = (contextEvent.params?.context as Record).id + const context = contextEvent.params?.context as Record + const contextId = context.id + const uniqueContextId = context.uniqueId expect(contextId).toBeTypeOf('number') + expect(uniqueContextId).toBeTypeOf('string') + + const evaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.__inspectorConsoleEvaluation = { answer: 6 * 7 }', + objectGroup: 'console', + includeCommandLineAPI: true, + silent: false, + returnByValue: false, + generatePreview: true, + userGesture: true, + awaitPromise: false, + replMode: true, + allowUnsafeEvalBlockedByCSP: false, + uniqueContextId, + }) + expect(evaluated.error).toBeUndefined() + expect(asRecord(evaluated.result?.result).objectId).toMatch(/^runtime:/u) + expect(await page.evaluate(() => Reflect.get(globalThis, '__inspectorConsoleEvaluation'))).toEqual({ answer: 42 }) await page.evaluate(() => { const value = { browser: true, nested: { ready: true } } diff --git a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts index cabeb5a1a5..c078b4e23c 100644 --- a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts +++ b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts @@ -79,7 +79,7 @@ describe('full fetch observer', () => { expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true }) }) - it('reports cancellation after response headers as a canceled request', async () => { + it('retains a truncated response when the caller cancels after response headers', async () => { const records: InspectorRecordInput[] = [] Object.defineProperty(globalThis, 'fetch', { value: vi.fn(async (request: Request) => new Response(new ReadableStream({ @@ -103,9 +103,41 @@ describe('full fetch observer', () => { const response = await fetch('https://example.test/cancel-body', { signal: abort.signal }) abort.abort() await expect(response.text()).rejects.toThrow() - await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/error')).toBe(true) }) + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) + + expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('first') + expect(payload(records, 'fetch/end')).toMatchObject({ + capturedBytes: 5, + responseBodyTruncated: true, + responseCaptureError: 'AbortError: aborted', + }) + expect(records.some(record => record.topic === 'fetch/error')).toBe(false) + }) + + it('reports a fetch rejected before response headers as a canceled request', async () => { + const records: InspectorRecordInput[] = [] + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(async (request: Request) => await new Promise((_resolve, reject) => { + request.signal.addEventListener('abort', () => { + reject(new DOMException('aborted', 'AbortError')) + }, { once: true }) + })), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + const abort = new AbortController() + + const pending = fetch('https://example.test/cancel-before-response', { signal: abort.signal }) + abort.abort() + await expect(pending).rejects.toThrow() expect(payload(records, 'fetch/error')).toMatchObject({ canceled: true }) + expect(records.some(record => record.topic === 'fetch/response')).toBe(false) expect(records.some(record => record.topic === 'fetch/end')).toBe(false) }) }) From b1748f0c55db76f133b3ef7ff042e78f5b36a34f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:48:37 +0800 Subject: [PATCH 071/130] fix(inspector): satisfy CI checks --- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 46 ++++++++++++++++- packages/experimental/inspector/README.zh.md | 46 ++++++++++++++++- .../inspector/src/client/bridge/rpc.ts | 49 ++----------------- .../inspector/src/client/bridge/transport.ts | 31 +++--------- .../inspector/src/client/inspection/cordis.ts | 26 +--------- .../inspector/src/host/bridge/rpc.ts | 45 ++--------------- .../inspector/src/host/bridge/transport.ts | 30 +++--------- .../inspector/src/host/cdp/index.ts | 24 ++++----- .../inspector/src/host/inspection/cordis.ts | 26 +--------- .../inspector/src/shared/bridge/publisher.ts | 23 ++++++++- .../inspector/src/shared/cordis/publisher.ts | 25 ++++++++++ .../inspector/tests/client-browser.e2e.ts | 4 +- .../tests/fixtures/client-source.host.ts | 2 +- .../inspector/tests/integration.host.spec.ts | 9 +--- .../inspector/tsconfig.client.json | 1 + .../experimental/inspector/tsconfig.host.json | 1 + 17 files changed, 185 insertions(+), 207 deletions(-) create mode 100644 packages/experimental/inspector/src/shared/cordis/publisher.ts diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index f146394b7a..6925e0ae2b 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md -README.md: dcebe9527de1b909fe1efc9f73977735ad374a95 -README.zh.md: 9ca43b0420f22969b6bd060bf9546eb094c4fbc0 +README.md: 86357b3a91763571ffe0cf9eea389b2b21c84d77 +README.zh.md: ce030fa3873f4cd488855d5c963a27eac0df9c8e diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index dcebe9527d..86357b3a91 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -1,11 +1,33 @@ +--- +description: "Experimental Chrome DevTools inspection for Host and browser Client Cordis runtimes, including Console evaluation, Sources, Network capture, Elements trees, and a CDP-independent query API." +kind: "package-reference" +--- + # @deepseek-ai/dsh-experimental-inspector English | [中文](README.zh.md) -Experimental Client/Host Cordis plugin that exposes one Chrome DevTools Protocol target from a Node worker thread. The Host and browser Client publish versioned observations into the Worker; the Worker is the sole owner of CDP state and output. +## Summary + +Use this experimental inspector to inspect one running dsh Host and its browser Clients in Chrome DevTools. It exposes Host and Client Console contexts, Host Sources and debugging, captured Host fetches, and a shared Cordis tree while keeping all CDP state in a Worker. The package is private and excluded from releases. The Worker never accesses live Cordis objects: the shared Host/Client collector projects them into validated snapshots before transport. Cordis also owns plugin composition, `ctx.inspector` registration, bootstrap injection, and disposal. +## Table of Contents + +- [Runtime layout](#runtime-layout) +- [Configuration](#configuration) +- [Observation API](#observation-api) +- [Cordis tree inspection](#cordis-tree-inspection) +- [Host fetch capture](#host-fetch-capture) +- [Security](#security) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + ## Runtime layout The Host plugin starts the Worker and connects a dedicated `MessagePort`. The Client plugin reads the injected `globalThis.__DSH_INSPECTOR__` bootstrap and opens a separate authenticated WebSocket directly to the Worker. Chrome DevTools connects to the Worker's CDP WebSocket. A private `node:inspector.Session` per DevTools connection attaches from the Worker to the Host main thread, so Host Console evaluation, Sources, breakpoints, and resume remain available while Host JavaScript is paused. @@ -18,6 +40,7 @@ Client sources declare typed Runtime, Console, and read-only Sources capabilitie Both plugin faces run the same browser-safe Cordis collector. It converts reachable Context and Fiber objects into a versioned `CordisTreeSnapshot`; the Worker stores that CDP-independent representation and projects each Host or Client source into the Elements panel. + ## Configuration The Host plugin injects `webServer` and accepts these fields: @@ -49,8 +72,11 @@ The Host plugin injects `webServer` and accepts these fields: | `maxCordisNodes` | `2048` | Context and Fiber nodes admitted from one realm snapshot before truncation | | `maxDisconnectedCordisTrees` | `8` | Last disconnected realm trees retained as non-live snapshots | +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-inspector) is the exhaustive source for accepted fields and their declarations. + The Host logs a `devtools://` URL after the Worker listens. The same Worker serves `/json`, `/json/list`, `/json/version`, the target WebSocket under `/devtools/page/`, and the Client source at `/ingest`. + ## Observation API Both plugin faces provide the same service: @@ -69,6 +95,7 @@ await ctx.inspector.cordis.getTree() Publishing validates lossless JSON and schedules delivery without waiting for the Worker. Each source has a bounded queue. Overflow is reported as a sequence gap and never delays the observed application operation. `cordis.getTree()` reads the Worker's latest detached semantic snapshot without creating a CDP session or enabling Runtime, Debugger, or Sources. + ## Cordis tree inspection The Elements document has fixed `` and `` containers. `` contains the Host root Context; `` contains one `` per Client source, and each `` contains that realm's root Context. The Cordis root Fiber is omitted. Every other Fiber is a child of `fiber.parent`, owns exactly one Context child for `fiber.ctx`, and carries only `uid=""`; Context elements have no attributes. Context-only `extend()`, `isolate()`, and `intercept()` layers remain direct Context descendants. @@ -77,16 +104,21 @@ Host and Client publish the same nested `CordisTreeSnapshot` type. Context and F When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately. + ## Host fetch capture Fetch capture is on by default and records the complete URL, all request and response headers, request body, response body, status, timing, errors, and cancellation. It does not redact credentials, cookies, query values, or payloads. Body capture reads clones; the caller receives the original Response as soon as the original fetch resolves. The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer. +After response headers arrive, a caller-side abort ends clone capture as a retained, possibly truncated response rather than a failed request. A fetch rejection before response headers remains a failed request. + + ## Security The CDP target grants arbitrary code execution in both Host and connected Client realms through `Runtime.evaluate`; Host Debugger operations provide additional control. Full fetch capture includes secrets. The Worker therefore accepts only a `127.0.0.1` bind address. Client ingest additionally requires a random WebSocket subprotocol token injected by the Host and rejects non-loopback origins unless explicitly configured. The CDP socket itself has no token; loopback binding is its only access control. + ## Model Experience None, as this developer-only inspector observes runtime activity without changing model requests. @@ -97,9 +129,21 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work + + - **Client active debugging is unsupported** — Console events, Runtime evaluation, RemoteObject access, and read-only `lib/client.js` Sources work. Client-script debugger requests return explicit unsupported errors; target-wide pause and resume control the Host only. - **Client Sources expose the Inspector bundle only** — other page scripts are not cataloged by this package. - **Client evaluation uses page JavaScript** — page Content Security Policy can block dynamic evaluation, and the synthetic context does not provide DevTools command-line helpers or native REPL declaration semantics. - **Fetch interception covers `globalThis.fetch`** — direct Undici APIs and fetch references retained before activation are not observed. - **Body cloning has cost** — full capture tees request and response streams up to the configured limits and can increase memory and I/O pressure. The retained-body limit does not include buffering inside the stream tee, including an oversized source chunk or data queued for a slower application reader. - **No automatic Worker restart** — an unexpected Worker exit fails the current Inspector instance; lifecycle recovery belongs to a later change. + + +### Dev Note + +
    +Working context for maintainers — click to expand + +None. + +
    diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index 9ca43b0420..ce030fa387 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -1,11 +1,33 @@ +--- +description: "面向 Host 与浏览器 Client Cordis 运行时的实验性 Chrome DevTools 检查,包括 Console 求值、Sources、Network 采集、Elements 树和独立于 CDP 的查询 API。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-experimental-inspector [English](README.md) | 中文 -实验性 Client/Host 双面 Cordis 插件,在 Node worker thread 中提供一个 Chrome DevTools Protocol target。Host 与浏览器 Client 向 Worker 发布带版本的观测记录;Worker 独占 CDP 状态与输出。 +## 概述 + +使用这个实验性 Inspector,可以在 Chrome DevTools 中检查一个运行中的 dsh Host 及其浏览器 Client。它提供 Host 与 Client Console context、Host Sources 与调试、Host fetch 采集和共享 Cordis 树,并让 Worker 独占全部 CDP 状态。 本包为私有包,不进入正式发布。Worker 不访问实时 Cordis 对象;共享 Host/Client collector 会在传输前把它们投影成已验证 snapshot。Cordis 还负责插件组合、注册 `ctx.inspector`、注入 bootstrap 和资源释放。 +## 目录 + +- [运行时布局](#runtime-layout) +- [配置](#configuration) +- [观测 API](#observation-api) +- [Cordis 树检查](#cordis-tree-inspection) +- [Host fetch 采集](#host-fetch-capture) +- [安全](#security) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + ## 运行时布局 Host 插件启动 Worker 并连接专用 `MessagePort`。Client 插件读取注入的 `globalThis.__DSH_INSPECTOR__` bootstrap,直接向 Worker 打开一条独立、带鉴权的 WebSocket。Chrome DevTools 连接 Worker 的 CDP WebSocket。每条 DevTools 连接在 Worker 中独占一个连接 Host 主线程的 `node:inspector.Session`,因此 Host JavaScript 暂停时,Host Console 求值、Sources、断点和 resume 仍然可用。 @@ -18,6 +40,7 @@ Client source 声明类型化 Runtime、Console 和只读 Sources 能力。`Runt 两个插件面运行同一份浏览器安全 Cordis collector。它把可达 Context 与 Fiber 对象转换成有版本的 `CordisTreeSnapshot`;Worker 存储这份与 CDP 无关的表示,并把每个 Host 或 Client source 投影到 Elements 面板。 + ## 配置 Host 插件注入 `webServer`,接受以下字段: @@ -49,8 +72,11 @@ Host 插件注入 `webServer`,接受以下字段: | `maxCordisNodes` | `2048` | 一个 realm snapshot 截断前允许的 Context 与 Fiber 节点数 | | `maxDisconnectedCordisTrees` | `8` | 作为非实时 snapshot 保留的最近断联 realm 树数量 | +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-inspector)是全部已接受字段及其声明的详尽来源。 + Worker 监听后,Host 会记录一个 `devtools://` URL。同一个 Worker 提供 `/json`、`/json/list`、`/json/version`、`/devtools/page/` target WebSocket 和 `/ingest` Client source。 + ## 观测 API 两个插件面都提供同一个服务: @@ -69,6 +95,7 @@ await ctx.inspector.cordis.getTree() 发布操作先验证无损 JSON,再调度发送,不等待 Worker。每个 source 的队列都有上限;溢出表现为 sequence gap,绝不延迟被观察的应用操作。`cordis.getTree()` 读取 Worker 最新的 detached semantic snapshot,不创建 CDP session,也不启用 Runtime、Debugger 或 Sources。 + ## Cordis tree inspection Elements document 包含固定的 `` 与 `` 容器。`` 包含 Host root Context;`` 为每个 Client source 包含一个 ``,每个 `` 再包含该 realm 的 root Context。Cordis root Fiber 不显示。其他 Fiber 都是 `fiber.parent` 的子节点,并包含唯一一个表示 `fiber.ctx` 的 Context 子节点;Fiber 只携带 `uid=""`,Context element 不携带 attribute。只有 Context 的 `extend()`、`isolate()` 与 `intercept()` 层仍然是直接 Context 后代。 @@ -77,16 +104,21 @@ Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。 + ## Host fetch 采集 fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、请求体、响应体、状态、时间、错误和取消。它不脱敏 credential、Cookie、query value 或 payload。body 采集读取 clone;原始 fetch resolve 后,调用方立即拿到原始 Response。 配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。 +response headers 到达后,调用方 abort 会结束 clone 采集,并保留一份可能 truncated 的 response,而不会把整个请求标记为失败。response headers 到达前发生的 fetch rejection 仍然是失败请求。 + + ## 安全 CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中的任意代码执行能力,Host Debugger 操作还会提供额外控制,完整 fetch 采集也包含秘密。因此 Worker 只接受 `127.0.0.1` 监听地址。Client ingest 还要求 Host 注入的随机 WebSocket subprotocol token;除非配置明确允许,否则拒绝非 loopback origin。CDP socket 本身不携带 token,loopback 监听是它唯一的访问控制。 + ## 模型体验 无:这个仅供开发者使用的 Inspector 只观察运行时活动,不改变模型请求。 @@ -97,9 +129,21 @@ CDP target 通过 `Runtime.evaluate` 提供 Host 和已连接 Client realm 中 ## Known Limitations and Deferred Work + + - **Client active debugging 不受支持**——Console event、Runtime 求值、RemoteObject 访问和只读 `lib/client.js` Sources 可用。Client script debugger request 返回明确的 unsupported error;target-wide pause 与 resume 只控制 Host。 - **Client Sources 只暴露 Inspector bundle**——本包不收录页面中的其他 script。 - **Client 求值使用页面 JavaScript**——页面 Content Security Policy 可能阻止动态求值;synthetic context 不提供 DevTools command-line helper 或原生 REPL 声明语义。 - **fetch 拦截范围是 `globalThis.fetch`**——直接调用 Undici API,以及激活前保存的 fetch 引用不会被观察。 - **body clone 有运行成本**——完整采集会 tee 请求与响应 stream,直至达到配置上限,可能增加内存与 I/O 压力。保留 body 的上限不包含 stream tee 内部的缓冲,包括来源提供的超大 chunk,或为读取较慢的应用分支排队的数据。 - **不自动重启 Worker**——Worker 意外退出会使当前 Inspector 实例失败;生命周期恢复留待后续改动。 + + +### 开发备注 + +
    +维护者的工作上下文——点击展开 + +无。 + +
    diff --git a/packages/experimental/inspector/src/client/bridge/rpc.ts b/packages/experimental/inspector/src/client/bridge/rpc.ts index 19b0c8d7dc..d34ad0d89b 100644 --- a/packages/experimental/inspector/src/client/bridge/rpc.ts +++ b/packages/experimental/inspector/src/client/bridge/rpc.ts @@ -1,62 +1,21 @@ /** Client-side non-CDP query bridge over the active Worker WebSocket. */ import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' -import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' -import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts' +import { InspectorQueryConnection } from '../../shared/bridge/rpc.ts' /** Owns query correlation across reconnecting Client source generations. */ -export class ClientBridgeRpc { - private readonly connection: InspectorQueryConnection - - constructor(options: InspectorQueryConnectionOptions) { - this.connection = new InspectorQueryConnection(options) - } - +export class ClientBridgeRpc extends InspectorQueryConnection { /** * Connect query writes to one accepted Client WebSocket generation. * @param source - Accepted source descriptor. * @param socket - Active source WebSocket. */ - connect(source: InspectorSourceDescriptor, socket: WebSocket): void { - this.connection.connect(source.sourceId, source.generation, { + connectSocket(source: InspectorSourceDescriptor, socket: WebSocket): void { + this.connect(source.sourceId, source.generation, { send: (frame) => { if (socket.readyState !== WebSocket.OPEN) throw new Error('Inspector Client query socket is not connected') socket.send(JSON.stringify(frame)) }, }) } - - /** - * Consume a potential query response. - * @param value - Decoded Worker message. - * @returns Whether the message belonged to this RPC protocol. - */ - receive(value: unknown): boolean { - return this.connection.receive(value) - } - - /** - * Execute one non-CDP query through the active Client generation. - * @param query - Typed query operation. - * @returns Its correlated typed result. - */ - request(query: Query): Promise> { - return this.connection.request(query) - } - - /** - * Reject pending requests while permitting a later Client generation. - * @param reason - Failure reported to pending callers. - */ - disconnect(reason: string): void { - this.connection.disconnect(reason) - } - - /** - * Permanently reject all current and future requests. - * @param reason - Failure reported to pending callers. - */ - close(reason: string): void { - this.connection.close(reason) - } } diff --git a/packages/experimental/inspector/src/client/bridge/transport.ts b/packages/experimental/inspector/src/client/bridge/transport.ts index 33ad831535..90ea7164f0 100644 --- a/packages/experimental/inspector/src/client/bridge/transport.ts +++ b/packages/experimental/inspector/src/client/bridge/transport.ts @@ -2,15 +2,14 @@ import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts' import type { InspectorSourceGeneration } from '../../shared/bridge/ids.ts' -import { isJsonValue, jsonByteLength, type InspectorJsonValue } from '../../shared/json.ts' -import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' +import { isJsonValue, jsonByteLength } from '../../shared/json.ts' import { INSPECTOR_PROTOCOL_VERSION, parseWorkerSourceFrame, type SourceCloseFrame, type SourceOpenFrame, } from '../../shared/bridge/messages/observation.ts' -import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { InspectorSourceConnection } from '../../shared/bridge/publisher.ts' import { ClientConsoleObserver } from '../cdp/console.ts' import { ClientRuntimeExecutor } from '../cdp/runtime.ts' import { @@ -27,16 +26,16 @@ import { ClientBridgeRpc } from './rpc.ts' import { dispatchBridgeFrame } from './dispatcher.ts' /** Reconnecting Client source whose bounded queue never blocks page work. */ -export class ClientInspectorSource implements InspectorConnection { +export class ClientInspectorSource extends InspectorSourceConnection { private readonly realmSource: ClientRealmSource - private readonly publisher: ClientBridgePublisher + protected readonly publisher: ClientBridgePublisher private socket: WebSocket | undefined private generation: InspectorSourceGeneration | undefined private accepted = false private closed = false private readonly runtime: ClientRuntimeExecutor private readonly console: ClientConsoleObserver - private readonly queries: ClientBridgeRpc + protected readonly queries: ClientBridgeRpc private readonly lifecycle: ClientBridgeLifecycle constructor( @@ -44,6 +43,7 @@ export class ClientInspectorSource implements InspectorConnection { label = document.title || 'Client', private readonly sourceCatalog: ClientSourceCatalog | undefined = discoverInspectorClientSourceCatalog(), ) { + super() this.realmSource = new ClientRealmSource(label) this.lifecycle = new ClientBridgeLifecycle(bootstrap.reconnectBaseMs, bootstrap.reconnectMaxMs) this.publisher = new ClientBridgePublisher({ @@ -87,23 +87,6 @@ export class ClientInspectorSource implements InspectorConnection { this.connect() } - /** Publish one JSON observation without waiting on the ingest socket. */ - publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { - if (this.closed) return - this.publisher.publish(topic, payload, monotonicMs) - } - - /** Retain and publish one state value for reconnect and resnapshot recovery. */ - setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { - if (this.closed) throw new Error('inspector: Client source is closed') - this.publisher.setState(topic, payload, monotonicMs) - } - - /** Execute one non-CDP query through the accepted Client source generation. */ - request(query: Query): Promise> { - return this.queries.request(query) - } - /** Permanently stop reconnecting and close the active source generation. */ close(): void { if (this.closed) return @@ -167,7 +150,7 @@ export class ClientInspectorSource implements InspectorConnection { accepted: () => { this.accepted = true this.lifecycle.connected() - this.queries.connect(source, socket) + this.queries.connectSocket(source, socket) this.publisher.accept(socket) }, resnapshot: () => { this.publisher.replace(socket) }, diff --git a/packages/experimental/inspector/src/client/inspection/cordis.ts b/packages/experimental/inspector/src/client/inspection/cordis.ts index 4492aa2e1b..6f4d1554fc 100644 --- a/packages/experimental/inspector/src/client/inspection/cordis.ts +++ b/packages/experimental/inspector/src/client/inspection/cordis.ts @@ -1,25 +1,3 @@ -/** Browser adapter that publishes shared Cordis snapshots over the Client bridge. */ +/** Client entry for the shared Cordis snapshot publisher. */ -import type { Context } from '@deepseek-ai/cordis' -import type { CordisTreeLimits } from '../../shared/cordis/collector.ts' -import { observeCordisTree } from '../../shared/cordis/observer.ts' -import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' -import type { InspectorJsonValue } from '../../shared/json.ts' -import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' - -/** - * Observe the Client Cordis runtime and retain its latest bridge snapshot. - * @param ctx - Client plugin context whose root is inspected. - * @param publisher - Active Client bridge publisher. - * @param limits - Snapshot node and encoded-byte limits. - * @returns A disposer that stops observation and releases retained objects. - */ -export function publishCordisTree( - ctx: Context, - publisher: InspectorStatePublisher, - limits: CordisTreeLimits, -): () => void { - return observeCordisTree(ctx, (snapshot) => { - publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) - }, limits) -} +export { publishCordisTree } from '../../shared/cordis/publisher.ts' diff --git a/packages/experimental/inspector/src/host/bridge/rpc.ts b/packages/experimental/inspector/src/host/bridge/rpc.ts index cf3429068b..ae3e7f87f6 100644 --- a/packages/experimental/inspector/src/host/bridge/rpc.ts +++ b/packages/experimental/inspector/src/host/bridge/rpc.ts @@ -2,58 +2,21 @@ import type { MessagePort } from 'node:worker_threads' import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' -import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' import { InspectorQueryConnection, type InspectorQueryConnectionOptions } from '../../shared/bridge/rpc.ts' /** Owns query correlation for one Host source generation. */ -export class HostBridgeRpc { - private readonly connection: InspectorQueryConnection - +export class HostBridgeRpc extends InspectorQueryConnection { constructor(private readonly port: MessagePort, options: InspectorQueryConnectionOptions) { - this.connection = new InspectorQueryConnection(options) + super(options) } /** * Connect query writes after the Worker accepts the Host source. * @param source - Accepted Host source descriptor. */ - connect(source: InspectorSourceDescriptor): void { - this.connection.connect(source.sourceId, source.generation, { + connectPort(source: InspectorSourceDescriptor): void { + this.connect(source.sourceId, source.generation, { send: (frame) => { this.port.postMessage(frame) }, }) } - - /** - * Consume a potential query response. - * @param value - Decoded Worker message. - * @returns Whether the message belonged to this RPC protocol. - */ - receive(value: unknown): boolean { - return this.connection.receive(value) - } - - /** - * Execute one non-CDP query through the active Host generation. - * @param query - Typed query operation. - * @returns Its correlated typed result. - */ - request(query: Query): Promise> { - return this.connection.request(query) - } - - /** - * Reject pending requests while retaining the reusable Host bridge. - * @param reason - Failure reported to pending callers. - */ - disconnect(reason: string): void { - this.connection.disconnect(reason) - } - - /** - * Permanently reject all current and future requests. - * @param reason - Failure reported to pending callers. - */ - close(reason: string): void { - this.connection.close(reason) - } } diff --git a/packages/experimental/inspector/src/host/bridge/transport.ts b/packages/experimental/inspector/src/host/bridge/transport.ts index b2eac495d5..fb526a020a 100644 --- a/packages/experimental/inspector/src/host/bridge/transport.ts +++ b/packages/experimental/inspector/src/host/bridge/transport.ts @@ -1,7 +1,6 @@ /** Host-realm observation publisher over a dedicated MessagePort. */ import type { MessagePort } from 'node:worker_threads' -import type { InspectorQuery, InspectorQueryResultFor } from '../../shared/bridge/messages/query/commands.ts' import { INSPECTOR_PROTOCOL_VERSION, parseWorkerSourceFrame, @@ -9,11 +8,10 @@ import { type SourceOpenFrame, type WorkerToSourceFrame, } from '../../shared/bridge/messages/observation.ts' -import type { InspectorConnection } from '../../shared/bridge/publisher.ts' +import { InspectorSourceConnection } from '../../shared/bridge/publisher.ts' import { createHostRealmSource } from '../inspection/realm.ts' import { HostBridgePublisher } from './publisher.ts' import { HostBridgeRpc } from './rpc.ts' -import type { InspectorJsonValue } from '../../shared/json.ts' import { dispatchBridgeFrame } from './dispatcher.ts' /** Buffer limits for one source publisher. */ @@ -28,13 +26,14 @@ export interface HostSourceOptions { } /** Non-blocking Host source; queue overflow is represented by `droppedBefore` on the next batch. */ -export class HostInspectorSource implements InspectorConnection { +export class HostInspectorSource extends InspectorSourceConnection { private readonly source - private readonly publisher: HostBridgePublisher + protected readonly publisher: HostBridgePublisher private closed = false - private readonly queries: HostBridgeRpc + protected readonly queries: HostBridgeRpc constructor(private readonly port: MessagePort, options: HostSourceOptions) { + super() this.source = createHostRealmSource(options.label) this.publisher = new HostBridgePublisher(port, this.source, options) this.queries = new HostBridgeRpc(port, { @@ -61,23 +60,6 @@ export class HostInspectorSource implements InspectorConnection { this.publisher.replace() } - /** Publish one observation without waiting on Worker processing. */ - publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { - if (this.closed) return - this.publisher.publish(topic, payload, monotonicMs) - } - - /** Retain and publish one state value for future `source/replace` frames. */ - setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { - if (this.closed) throw new Error('inspector: Host source is closed') - this.publisher.setState(topic, payload, monotonicMs) - } - - /** Execute one non-CDP query through the accepted Host source generation. */ - request(query: Query): Promise> { - return this.queries.request(query) - } - /** Flush pending observations and close the source port. */ close(): void { if (this.closed) return @@ -98,7 +80,7 @@ export class HostInspectorSource implements InspectorConnection { if (frame.t !== 'source/rejected' && (frame.sourceId !== this.source.sourceId || frame.generation !== this.source.generation)) return dispatchBridgeFrame(frame, { - accepted: () => { this.queries.connect(this.source) }, + accepted: () => { this.queries.connectPort(this.source) }, resnapshot: () => { this.publisher.replace() }, rejected: (rejected) => { this.queries.disconnect(`Inspector Host source rejected: ${rejected.message}`) }, }) diff --git a/packages/experimental/inspector/src/host/cdp/index.ts b/packages/experimental/inspector/src/host/cdp/index.ts index de7fb195c6..fca57c204b 100644 --- a/packages/experimental/inspector/src/host/cdp/index.ts +++ b/packages/experimental/inspector/src/host/cdp/index.ts @@ -8,19 +8,21 @@ import { profilerBridgeCapability } from './profiler.ts' import { runtimeBridgeCapability } from './runtime.ts' import { sourcesBridgeCapability } from './sources.ts' +const HOST_BRIDGE_CAPABILITIES: readonly InspectorSourceCapability[] = [ + runtimeBridgeCapability(''), + consoleBridgeCapability(), + sourcesBridgeCapability(false), + debuggerBridgeCapability(), + profilerBridgeCapability(), + heapProfilerBridgeCapability(), +].filter((capability): capability is InspectorSourceCapability => capability !== undefined) + /** * Collect Host source-bridge capabilities. - * @param origin - Unused Host origin supplied for parity with the Client adapter. - * @param hasSources - Unused source availability supplied for parity with the Client adapter. + * @param _origin - Unused Host origin supplied for parity with the Client adapter. + * @param _hasSources - Unused source availability supplied for parity with the Client adapter. * @returns No capabilities because the Worker attaches to Host V8 directly. */ -export function bridgeCapabilities(origin: string, hasSources: boolean): readonly InspectorSourceCapability[] { - return [ - runtimeBridgeCapability(origin), - consoleBridgeCapability(), - sourcesBridgeCapability(hasSources), - debuggerBridgeCapability(), - profilerBridgeCapability(), - heapProfilerBridgeCapability(), - ].filter((capability): capability is InspectorSourceCapability => capability !== undefined) +export function bridgeCapabilities(_origin: string, _hasSources: boolean): readonly InspectorSourceCapability[] { + return HOST_BRIDGE_CAPABILITIES } diff --git a/packages/experimental/inspector/src/host/inspection/cordis.ts b/packages/experimental/inspector/src/host/inspection/cordis.ts index 871b234d5e..7fedad8caf 100644 --- a/packages/experimental/inspector/src/host/inspection/cordis.ts +++ b/packages/experimental/inspector/src/host/inspection/cordis.ts @@ -1,25 +1,3 @@ -/** Host adapter that publishes shared Cordis snapshots over the Host bridge. */ +/** Host entry for the shared Cordis snapshot publisher. */ -import type { Context } from '@deepseek-ai/cordis' -import type { CordisTreeLimits } from '../../shared/cordis/collector.ts' -import { observeCordisTree } from '../../shared/cordis/observer.ts' -import { CORDIS_TREE_TOPIC } from '../../shared/bridge/messages/cordis.ts' -import type { InspectorJsonValue } from '../../shared/json.ts' -import type { InspectorStatePublisher } from '../../shared/bridge/publisher.ts' - -/** - * Observe the Host Cordis runtime and retain its latest bridge snapshot. - * @param ctx - Host plugin context whose root is inspected. - * @param publisher - Active Host bridge publisher. - * @param limits - Snapshot node and encoded-byte limits. - * @returns A disposer that stops observation and releases retained objects. - */ -export function publishCordisTree( - ctx: Context, - publisher: InspectorStatePublisher, - limits: CordisTreeLimits, -): () => void { - return observeCordisTree(ctx, (snapshot) => { - publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) - }, limits) -} +export { publishCordisTree } from '../../shared/cordis/publisher.ts' diff --git a/packages/experimental/inspector/src/shared/bridge/publisher.ts b/packages/experimental/inspector/src/shared/bridge/publisher.ts index a383f587c9..ff840767bd 100644 --- a/packages/experimental/inspector/src/shared/bridge/publisher.ts +++ b/packages/experimental/inspector/src/shared/bridge/publisher.ts @@ -1,7 +1,7 @@ /** Source-side interfaces shared by MessagePort and WebSocket bridge implementations. */ import type { InspectorJsonValue } from '../json.ts' -import type { InspectorQueryRequester } from './messages/query/commands.ts' +import type { InspectorQuery, InspectorQueryRequester, InspectorQueryResultFor } from './messages/query/commands.ts' /** Transport-independent observation publisher. */ export interface InspectorPublisher { @@ -22,3 +22,24 @@ export interface InspectorStatePublisher extends InspectorPublisher { /** Shared capabilities exposed above a Host MessagePort or Client WebSocket carrier. */ export interface InspectorConnection extends InspectorStatePublisher, InspectorQueryRequester {} + +/** Shared observation and query delegation inherited by both source transports. */ +export abstract class InspectorSourceConnection implements InspectorConnection { + protected abstract readonly publisher: InspectorStatePublisher + protected abstract readonly queries: InspectorQueryRequester + + /** Publish one JSON observation without waiting on its carrier. */ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + this.publisher.publish(topic, payload, monotonicMs) + } + + /** Retain and publish one state value for reconnect or replacement recovery. */ + setState(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()): void { + this.publisher.setState(topic, payload, monotonicMs) + } + + /** Execute one non-CDP query through the active source generation. */ + request(query: Query): Promise> { + return this.queries.request(query) + } +} diff --git a/packages/experimental/inspector/src/shared/cordis/publisher.ts b/packages/experimental/inspector/src/shared/cordis/publisher.ts new file mode 100644 index 0000000000..0519e302d8 --- /dev/null +++ b/packages/experimental/inspector/src/shared/cordis/publisher.ts @@ -0,0 +1,25 @@ +/** Shared Host/Client publication of browser-safe Cordis snapshots. */ + +import type { Context } from '@deepseek-ai/cordis' +import { CORDIS_TREE_TOPIC } from '../bridge/messages/cordis.ts' +import type { InspectorStatePublisher } from '../bridge/publisher.ts' +import type { InspectorJsonValue } from '../json.ts' +import type { CordisTreeLimits } from './collector.ts' +import { observeCordisTree } from './observer.ts' + +/** + * Observe one Cordis runtime and retain its latest source snapshot. + * @param ctx - Plugin context whose root is inspected. + * @param publisher - Active Host or Client source publisher. + * @param limits - Snapshot node and encoded-byte limits. + * @returns A disposer that stops observation and releases retained objects. + */ +export function publishCordisTree( + ctx: Context, + publisher: InspectorStatePublisher, + limits: CordisTreeLimits, +): () => void { + return observeCordisTree(ctx, (snapshot) => { + publisher.setState(CORDIS_TREE_TOPIC, snapshot as unknown as InspectorJsonValue) + }, limits) +} diff --git a/packages/experimental/inspector/tests/client-browser.e2e.ts b/packages/experimental/inspector/tests/client-browser.e2e.ts index f4f722ebe3..c7eac273f3 100644 --- a/packages/experimental/inspector/tests/client-browser.e2e.ts +++ b/packages/experimental/inspector/tests/client-browser.e2e.ts @@ -168,7 +168,9 @@ describe.skipIf(!built)('Inspector built Client in Chromium', () => { }) expect(evaluated.error).toBeUndefined() expect(asRecord(evaluated.result?.result).objectId).toMatch(/^runtime:/u) - expect(await page.evaluate(() => Reflect.get(globalThis, '__inspectorConsoleEvaluation'))).toEqual({ answer: 42 }) + expect(await page.evaluate(() => Reflect.get(globalThis, '__inspectorConsoleEvaluation') as unknown)).toEqual({ + answer: 42, + }) await page.evaluate(() => { const value = { browser: true, nested: { ready: true } } diff --git a/packages/experimental/inspector/tests/fixtures/client-source.host.ts b/packages/experimental/inspector/tests/fixtures/client-source.host.ts index f63d71a881..8b817c49c4 100644 --- a/packages/experimental/inspector/tests/fixtures/client-source.host.ts +++ b/packages/experimental/inspector/tests/fixtures/client-source.host.ts @@ -6,7 +6,7 @@ import type { CordisRuntimeTree } from '../../src/shared/cordis/model.ts' import type { InspectorJsonValue } from '../../src/shared/json.ts' /** Optional source artifact exposed by the Client fixture. */ -export interface ClientFixtureSourceCatalog { +interface ClientFixtureSourceCatalog { readonly sourceText: string readonly sourceMap: string readonly sourceUrl: string diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts index b5c7bd914e..1e0ab8469a 100644 --- a/packages/experimental/inspector/tests/integration.host.spec.ts +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -90,13 +90,8 @@ describe('experimental Inspector real Worker', () => { await vi.waitFor(async () => { const response = await cdp!.call('DSHInspector.getSources') const sources = response.result?.sources as Array<{ kind: string; topics: Record }> - expect(sources).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: 'host', topics: { 'host/probe': 1 } }), - expect.objectContaining({ - kind: 'client', - topics: expect.objectContaining({ 'client/probe': 1 }), - }), - ])) + expect(sources.find(source => source.kind === 'host')?.topics).toEqual({ 'host/probe': 1 }) + expect(sources.find(source => source.kind === 'client')?.topics).toMatchObject({ 'client/probe': 1 }) }) ;(globalThis as Record).__inspectorHostProbe = 73 diff --git a/packages/experimental/inspector/tsconfig.client.json b/packages/experimental/inspector/tsconfig.client.json index 402c7f4c6a..0c5a2b1f72 100644 --- a/packages/experimental/inspector/tsconfig.client.json +++ b/packages/experimental/inspector/tsconfig.client.json @@ -72,6 +72,7 @@ "src/shared/cordis/object-reference.ts", "src/shared/cordis/object-registry.ts", "src/shared/cordis/observer.ts", + "src/shared/cordis/publisher.ts", "src/shared/cordis/projector.ts", "src/shared/cordis/reader.ts", "src/shared/cordis/snapshot.ts", diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json index 84235269cd..45c311d863 100644 --- a/packages/experimental/inspector/tsconfig.host.json +++ b/packages/experimental/inspector/tsconfig.host.json @@ -74,6 +74,7 @@ "src/shared/cordis/object-reference.ts", "src/shared/cordis/object-registry.ts", "src/shared/cordis/observer.ts", + "src/shared/cordis/publisher.ts", "src/shared/cordis/projector.ts", "src/shared/cordis/reader.ts", "src/shared/cordis/snapshot.ts", From 0954bad2bbcacf350e6493749cd9758cb0984b43 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 05:05:37 +0800 Subject: [PATCH 072/130] fix(inspector): render captured event streams --- .../inspector/src/host/inspection/network.ts | 2 +- .../src/shared/network/event-source.ts | 77 +++++++++++++++ .../src/shared/network/observation.ts | 7 ++ .../src/worker/cdp/domains/network/session.ts | 95 +++++++++++++------ .../src/worker/inspection/network-store.ts | 64 +++++++++++-- .../inspector/tests/event-source.host.spec.ts | 31 ++++++ .../inspector/tests/integration.host.spec.ts | 21 ++++ .../inspector/tests/network.host.spec.ts | 88 +++++++++++++++++ .../inspector/tsconfig.client.json | 1 + .../experimental/inspector/tsconfig.host.json | 1 + 10 files changed, 350 insertions(+), 37 deletions(-) create mode 100644 packages/experimental/inspector/src/shared/network/event-source.ts create mode 100644 packages/experimental/inspector/tests/event-source.host.spec.ts diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts index fcbdadb76d..aa5225a8c5 100644 --- a/packages/experimental/inspector/src/host/inspection/network.ts +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -108,7 +108,7 @@ export function installFetchObserver( status: response.status, statusText: response.statusText, headers: headerEntries(response.headers), - mimeType: response.headers.get('content-type')?.split(';', 1)[0]?.trim() ?? '', + mimeType: response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() ?? '', }) try { diff --git a/packages/experimental/inspector/src/shared/network/event-source.ts b/packages/experimental/inspector/src/shared/network/event-source.ts new file mode 100644 index 0000000000..29c40a5a1e --- /dev/null +++ b/packages/experimental/inspector/src/shared/network/event-source.ts @@ -0,0 +1,77 @@ +/** Incremental UTF-8 parser for Server-Sent Events carried by captured responses. */ + +import type { InspectorEventSourceMessage } from './observation.ts' + +/** Parse response bytes into consumer-neutral Server-Sent Event messages. */ +export class InspectorEventSourceParser { + private readonly decoder = new TextDecoder() + private line = '' + private eventName = '' + private eventId = '' + private data = '' + private afterCarriageReturn = false + + /** + * Consume one response-body chunk. + * @param bytes - Next bytes in response order. + * @returns Complete events terminated by an empty line in this chunk. + */ + push(bytes: Uint8Array): readonly InspectorEventSourceMessage[] { + return this.consume(this.decoder.decode(bytes, { stream: true })) + } + + private consume(chunk: string): InspectorEventSourceMessage[] { + const messages: InspectorEventSourceMessage[] = [] + let start = 0 + for (let index = 0; index < chunk.length; index++) { + if (this.afterCarriageReturn && chunk[index] === '\n') { + this.afterCarriageReturn = false + start = index + 1 + continue + } + this.afterCarriageReturn = false + if (chunk[index] !== '\r' && chunk[index] !== '\n') continue + this.line += chunk.slice(start, index) + const message = this.parseLine() + if (message !== undefined) messages.push(message) + this.line = '' + start = index + 1 + this.afterCarriageReturn = chunk[index] === '\r' + } + this.line += chunk.slice(start) + return messages + } + + private parseLine(): InspectorEventSourceMessage | undefined { + if (this.line.length === 0) { + const data = this.data + this.data = '' + const eventName = this.eventName + this.eventName = '' + if (data.length === 0) return undefined + return { + eventName: eventName || 'message', + eventId: this.eventId, + data: data.slice(0, -1), + } + } + if (this.line.startsWith(':')) return undefined + const colon = this.line.indexOf(':') + const field = colon === -1 ? this.line : this.line.slice(0, colon) + let value = colon === -1 ? '' : this.line.slice(colon + 1) + if (value.startsWith(' ')) value = value.slice(1) + switch (field) { + case 'event': + this.eventName = value + return undefined + case 'data': + this.data += `${value}\n` + return undefined + case 'id': + if (!value.includes('\0')) this.eventId = value + return undefined + default: + return undefined + } + } +} diff --git a/packages/experimental/inspector/src/shared/network/observation.ts b/packages/experimental/inspector/src/shared/network/observation.ts index b3266dc7c7..c1cfadbb7d 100644 --- a/packages/experimental/inspector/src/shared/network/observation.ts +++ b/packages/experimental/inspector/src/shared/network/observation.ts @@ -46,6 +46,13 @@ export interface FetchEndPayload extends FetchIdentity { readonly responseCaptureError?: string } +/** One parsed Server-Sent Event independent of its CDP projection. */ +export interface InspectorEventSourceMessage { + readonly eventName: string + readonly eventId: string + readonly data: string +} + /** Fetch rejected before returning a Response. */ export interface FetchErrorPayload extends FetchIdentity { readonly message: string diff --git a/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts index 1988eca41a..c7ad184007 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/network/session.ts @@ -9,10 +9,15 @@ export interface NetworkSink { sendEvent(method: string, params: Readonly>): void } +type RequestStartedEvent = Extract +type NetworkResourceType = 'EventSource' | 'Fetch' + /** Projects retained and live network observations into connection-local CDP state. */ export class NetworkDomain { private readonly enabled = new Set() private readonly streamedRequests = new Map>() + private readonly pendingStarts = new Map>() + private readonly requestTypes = new Map>() private readonly unsubscribe: () => void constructor(private readonly store: NetworkStore) { @@ -25,8 +30,10 @@ export class NetworkDomain { */ enable(session: NetworkSink): void { if (this.enabled.has(session)) return - for (const event of this.store.replay()) this.send(session, event) this.enabled.add(session) + this.pendingStarts.set(session, new Map()) + this.requestTypes.set(session, new Map()) + for (const event of this.store.replay()) this.send(session, event) } /** @@ -36,6 +43,8 @@ export class NetworkDomain { disable(session: NetworkSink): void { this.enabled.delete(session) this.streamedRequests.delete(session) + this.pendingStarts.delete(session) + this.requestTypes.delete(session) } /** @@ -51,6 +60,8 @@ export class NetworkDomain { this.unsubscribe() this.enabled.clear() this.streamedRequests.clear() + this.pendingStarts.clear() + this.requestTypes.clear() } /** @@ -112,6 +123,8 @@ export class NetworkDomain { requests.delete(event.requestKey) if (requests.size === 0) this.streamedRequests.delete(session) } + for (const requests of this.pendingStarts.values()) requests.delete(event.requestKey) + for (const requests of this.requestTypes.values()) requests.delete(event.requestKey) return } for (const session of this.enabled) this.send(session, event) @@ -121,29 +134,17 @@ export class NetworkDomain { const timestamp = (event.timestampMs - performance.timeOrigin) / 1_000 switch (event.type) { case 'request-started': - session.sendEvent('Network.requestWillBeSent', { - requestId: event.requestId, - loaderId: 'dsh-inspector-loader', - documentURL: 'dsh://host', - request: { - url: event.url, - method: event.method, - headers: cdpHeaders(event.headers), - hasPostData: event.hasBody, - }, - timestamp, - wallTime: event.wallTimeMs / 1_000, - initiator: { type: 'other' }, - type: 'Fetch', - }) + this.pendingStarts.get(session)?.set(event.requestKey, event) return - case 'response-received': + case 'response-received': { + const resourceType = event.mimeType === 'text/event-stream' ? 'EventSource' : 'Fetch' + this.sendRequestStart(session, event.requestKey, resourceType) session.sendEvent('Network.responseReceived', { requestId: event.requestId, loaderId: 'dsh-inspector-loader', frameId: 'dsh-inspector-host-frame', timestamp, - type: 'Fetch', + type: resourceType, response: { url: event.url, status: event.status, @@ -152,11 +153,21 @@ export class NetworkDomain { mimeType: event.mimeType, connectionReused: false, connectionId: 0, - encodedDataLength: 0, + encodedDataLength: resourceType === 'EventSource' ? -1 : 0, securityState: 'neutral', }, }) return + } + case 'event-source-message': + session.sendEvent('Network.eventSourceMessageReceived', { + requestId: event.requestId, + timestamp, + eventName: event.eventName, + eventId: event.eventId, + data: event.data, + }) + return case 'response-data': session.sendEvent('Network.dataReceived', { requestId: event.requestId, @@ -167,34 +178,62 @@ export class NetworkDomain { }) return case 'request-finished': + this.sendRequestStart(session, event.requestKey, 'Fetch') session.sendEvent('Network.loadingFinished', { requestId: event.requestId, timestamp, encodedDataLength: event.encodedDataLength, dshInspectorTruncated: event.truncated, }) - this.stopStreaming(event.requestKey) + this.stopRequest(session, event.requestKey) return - case 'request-failed': + case 'request-failed': { + this.sendRequestStart(session, event.requestKey, 'Fetch') + const resourceType = this.requestTypes.get(session)?.get(event.requestKey) ?? 'Fetch' session.sendEvent('Network.loadingFailed', { requestId: event.requestId, timestamp, - type: 'Fetch', + type: resourceType, errorText: event.errorText, canceled: event.canceled, }) - this.stopStreaming(event.requestKey) + this.stopRequest(session, event.requestKey) return + } default: return assertNever(event) } } - private stopStreaming(requestKey: string): void { - for (const [session, requests] of this.streamedRequests) { - requests.delete(requestKey) - if (requests.size === 0) this.streamedRequests.delete(session) - } + private sendRequestStart(session: NetworkSink, requestKey: string, resourceType: NetworkResourceType): void { + const pending = this.pendingStarts.get(session) + const event = pending?.get(requestKey) + if (event === undefined) return + pending?.delete(requestKey) + this.requestTypes.get(session)?.set(requestKey, resourceType) + session.sendEvent('Network.requestWillBeSent', { + requestId: event.requestId, + loaderId: 'dsh-inspector-loader', + documentURL: 'dsh://host', + request: { + url: event.url, + method: event.method, + headers: cdpHeaders(event.headers), + hasPostData: event.hasBody, + }, + timestamp: (event.timestampMs - performance.timeOrigin) / 1_000, + wallTime: event.wallTimeMs / 1_000, + initiator: { type: 'other' }, + type: resourceType, + }) + } + + private stopRequest(session: NetworkSink, requestKey: string): void { + const streamed = this.streamedRequests.get(session) + streamed?.delete(requestKey) + if (streamed?.size === 0) this.streamedRequests.delete(session) + this.pendingStarts.get(session)?.delete(requestKey) + this.requestTypes.get(session)?.delete(requestKey) } } diff --git a/packages/experimental/inspector/src/worker/inspection/network-store.ts b/packages/experimental/inspector/src/worker/inspection/network-store.ts index 1599667503..cf1d80b90c 100644 --- a/packages/experimental/inspector/src/worker/inspection/network-store.ts +++ b/packages/experimental/inspector/src/worker/inspection/network-store.ts @@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer' import { FETCH_TOPICS } from '../../shared/bridge/messages/network.ts' import type { InspectorHeader } from '../../shared/network/observation.ts' +import { InspectorEventSourceParser } from '../../shared/network/event-source.ts' import { isPlainObject } from '../../shared/json.ts' import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/observation.ts' import type { IngestedInspectorRecord, InspectorRecordConsumer } from '../bridge/hub.ts' @@ -50,6 +51,12 @@ export type NetworkStoreEvent = readonly data: string readonly byteLength: number } + | NetworkEventBase & { + readonly type: 'event-source-message' + readonly eventName: string + readonly eventId: string + readonly data: string + } | NetworkEventBase & { readonly type: 'request-finished' readonly encodedDataLength: number @@ -62,6 +69,9 @@ export type NetworkStoreEvent = } | { readonly type: 'request-evicted'; readonly requestKey: string } +type JournalNetworkEvent = Exclude type ReplayableNetworkEvent = Exclude interface CapturedRequest { @@ -78,13 +88,15 @@ interface CapturedRequest { responseCaptureError?: string responseSeen: boolean completed: boolean + eventSourceParser: InspectorEventSourceParser | undefined + nextEventSourceId: number } /** Validated Network observation store independent of CDP connection state. */ export class NetworkStore implements InspectorRecordConsumer { readonly topics = new Set(FETCH_TOPICS) private readonly requests = new Map() - private readonly journal: ReplayableNetworkEvent[] = [] + private readonly journal: JournalNetworkEvent[] = [] private readonly completed: string[] = [] private readonly listeners = new Set<(event: NetworkStoreEvent) => void>() private journalBytes = 0 @@ -129,7 +141,26 @@ export class NetworkStore implements InspectorRecordConsumer { * @returns Events in observation order. */ replay(): readonly ReplayableNetworkEvent[] { - return this.journal + const replay: ReplayableNetworkEvent[] = [] + for (const event of this.journal) { + replay.push(event) + if (event.type !== 'response-received' || event.mimeType !== 'text/event-stream') continue + const request = this.requests.get(event.requestKey) + if (request === undefined) continue + const messages = new InspectorEventSourceParser().push(Buffer.concat(request.responseBody)) + let eventId = 0 + for (const message of messages) { + replay.push({ + type: 'event-source-message', + requestKey: request.key, + requestId: request.requestId, + timestampMs: event.timestampMs, + ...message, + eventId: String(++eventId), + }) + } + } + return replay } /** @@ -191,6 +222,8 @@ export class NetworkStore implements InspectorRecordConsumer { responseBodyTruncated: false, responseSeen: false, completed: false, + eventSourceParser: undefined, + nextEventSourceId: 0, } this.requests.set(key, request) this.publish({ @@ -221,6 +254,10 @@ export class NetworkStore implements InspectorRecordConsumer { } case 'fetch/response': request.responseSeen = true + const mimeType = stringField(payload, 'mimeType').toLowerCase() + request.eventSourceParser = mimeType === 'text/event-stream' + ? new InspectorEventSourceParser() + : undefined this.publish({ type: 'response-received', requestKey: key, @@ -230,12 +267,23 @@ export class NetworkStore implements InspectorRecordConsumer { status: numberField(payload, 'status'), statusText: stringField(payload, 'statusText'), headers: headerField(payload, 'headers'), - mimeType: stringField(payload, 'mimeType'), + mimeType, }) return case 'fetch/response-body-chunk': { const data = stringField(payload, 'data') - const byteLength = this.appendBody(request, 'response', data) + const bytes = this.appendBody(request, 'response', data) + const byteLength = bytes.byteLength + for (const message of request.eventSourceParser?.push(bytes) ?? []) { + this.emit({ + type: 'event-source-message', + requestKey: key, + requestId: request.requestId, + timestampMs, + ...message, + eventId: String(++request.nextEventSourceId), + }) + } this.emit({ type: 'response-data', requestKey: key, requestId: request.requestId, timestampMs, data, byteLength }) return } @@ -268,7 +316,7 @@ export class NetworkStore implements InspectorRecordConsumer { } } - private appendBody(request: CapturedRequest, side: 'request' | 'response', encoded: string): number { + private appendBody(request: CapturedRequest, side: 'request' | 'response', encoded: string): Buffer { const bytes = decodeBase64(encoded) this.evictCompletedFor(bytes.byteLength, request.key) const retained = bytes.subarray(0, Math.max(0, this.options.maxJournalBytes - this.journalBytes)) @@ -283,10 +331,10 @@ export class NetworkStore implements InspectorRecordConsumer { } this.journalBytes += retained.byteLength this.enforceRetention() - return bytes.byteLength + return bytes } - private complete(request: CapturedRequest, event: ReplayableNetworkEvent): void { + private complete(request: CapturedRequest, event: JournalNetworkEvent): void { if (request.completed) return request.completed = true this.publish(event) @@ -294,7 +342,7 @@ export class NetworkStore implements InspectorRecordConsumer { this.enforceRetention() } - private publish(event: ReplayableNetworkEvent): void { + private publish(event: JournalNetworkEvent): void { this.journal.push(event) this.emit(event) } diff --git a/packages/experimental/inspector/tests/event-source.host.spec.ts b/packages/experimental/inspector/tests/event-source.host.spec.ts new file mode 100644 index 0000000000..cec6f87345 --- /dev/null +++ b/packages/experimental/inspector/tests/event-source.host.spec.ts @@ -0,0 +1,31 @@ +/** Consumer-neutral Server-Sent Event parsing behavior. */ + +import { describe, expect, it } from 'vitest' +import { InspectorEventSourceParser } from '../src/shared/network/event-source.ts' + +const encoder = new TextEncoder() + +describe('InspectorEventSourceParser', () => { + it('preserves parser state across chunks, CRLF boundaries, and UTF-8 boundaries', () => { + const parser = new InspectorEventSourceParser() + expect(parser.push(encoder.encode(': ignored\rid:first\revent: update\rdata: one\r'))).toEqual([]) + + const unicode = encoder.encode('\ndata: two 你\r\n\r\n') + const split = unicode.indexOf(0xe4) + 1 + expect(parser.push(unicode.subarray(0, split))).toEqual([]) + expect(parser.push(unicode.subarray(split))).toEqual([{ + eventName: 'update', + eventId: 'first', + data: 'one\ntwo 你', + }]) + }) + + it('retains valid ids, ignores comments and unknown fields, and emits empty data', () => { + const parser = new InspectorEventSourceParser() + expect(parser.push(encoder.encode('retry: 1000\nunknown\n\n'))).toEqual([]) + expect(parser.push(encoder.encode('id: stable\ndata: value\n\nid: bad\0id\ndata:\n\n'))).toEqual([ + { eventName: 'message', eventId: 'stable', data: 'value' }, + { eventName: 'message', eventId: 'stable', data: '' }, + ]) + }) +}) diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts index 1e0ab8469a..79fcfe60ea 100644 --- a/packages/experimental/inspector/tests/integration.host.spec.ts +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -466,6 +466,20 @@ describe('experimental Inspector real Worker', () => { && (event.params?.response as Record | undefined)?.mimeType === 'text/event-stream') requestId = received?.params?.requestId as string | undefined expect(requestId).toBeTypeOf('string') + expect(received?.params).toMatchObject({ + type: 'EventSource', + response: { encodedDataLength: -1 }, + }) + expect(cdp!.events.find(event => + event.method === 'Network.requestWillBeSent' + && event.params?.requestId === requestId)?.params?.type).toBe('EventSource') + expect(cdp!.events.find(event => + event.method === 'Network.eventSourceMessageReceived' + && event.params?.requestId === requestId)?.params).toMatchObject({ + eventName: 'message', + eventId: '1', + data: 'first', + }) expect(cdp!.events.some(event => event.method === 'Network.dataReceived' && event.params?.requestId === requestId)).toBe(true) @@ -488,6 +502,13 @@ describe('experimental Inspector real Worker', () => { && typeof event.params?.data === 'string') .map(event => Buffer.from(String(event.params!.data), 'base64')) expect(Buffer.concat(streamed).toString('utf8')).toBe(laterChunk) + expect(cdp!.events.slice(laterEventOffset).find(event => + event.method === 'Network.eventSourceMessageReceived' + && event.params?.requestId === requestId)?.params).toMatchObject({ + eventName: 'update', + eventId: '2', + data: 'second\nline', + }) }) const body = await cdp.call('Network.getResponseBody', { requestId }) diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts index 05273e38e1..a658571a8d 100644 --- a/packages/experimental/inspector/tests/network.host.spec.ts +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -89,6 +89,50 @@ describe('Inspector Network domain', () => { expect(secondSend.mock.calls.findLast(call => call[0] === 'Network.dataReceived')?.[1]).toMatchObject({ data: later }) }) + it('projects and replays parsed Server-Sent Events through the CDP EventSource path', () => { + const liveSend = vi.fn() + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const network = new NetworkDomain(store) + network.enable({ sendEvent: liveSend }) + store.append(source, eventStreamRecords('events')) + + expect(liveSend).toHaveBeenNthCalledWith(1, 'Network.requestWillBeSent', expect.objectContaining({ + type: 'EventSource', + })) + expect(liveSend).toHaveBeenCalledWith('Network.responseReceived', expect.objectContaining({ + type: 'EventSource', + })) + expect(liveSend.mock.calls + .filter(call => call[0] === 'Network.eventSourceMessageReceived') + .map(call => call[1] as unknown)) + .toEqual([ + expect.objectContaining({ eventName: 'message', eventId: '1', data: 'first' }), + expect.objectContaining({ eventName: 'update', eventId: '2', data: 'second\nline' }), + ]) + expect(liveSend.mock.calls.map(call => String(call[0]))).toEqual([ + 'Network.requestWillBeSent', + 'Network.responseReceived', + 'Network.eventSourceMessageReceived', + 'Network.dataReceived', + 'Network.eventSourceMessageReceived', + 'Network.dataReceived', + 'Network.loadingFinished', + ]) + + const replay = vi.fn() + network.enable({ sendEvent: replay }) + expect(replay).toHaveBeenNthCalledWith(1, 'Network.requestWillBeSent', expect.objectContaining({ + type: 'EventSource', + })) + expect(replay.mock.calls + .filter(call => call[0] === 'Network.eventSourceMessageReceived') + .map(call => call[1] as unknown)) + .toEqual([ + expect.objectContaining({ eventName: 'message', eventId: '1', data: 'first' }), + expect.objectContaining({ eventName: 'update', eventId: '2', data: 'second\nline' }), + ]) + }) + it('bounds active request metadata and does not retain per-chunk events for replay', () => { const firstSend = vi.fn() const store = new NetworkStore({ maxRetainedRequests: 1, maxJournalBytes: 1_024 }) @@ -148,6 +192,50 @@ function requestRecords(localId: string, body: string): IngestedInspectorRecord[ ] } +function eventStreamRecords(localId: string): IngestedInspectorRecord[] { + const first = 'id: 1\ndata: first\n\n' + const second = 'id: 2\nevent: update\ndata: second\ndata: line\n\n' + return [ + { + sequence: 1, + monotonicMs: 1, + topic: 'fetch/start', + payload: { requestId: localId, url: 'https://example.test/events', method: 'GET', headers: [], hasBody: false, wallTimeMs: 1 }, + }, + { + sequence: 2, + monotonicMs: 2, + topic: 'fetch/response', + payload: { + requestId: localId, + url: 'https://example.test/events', + status: 200, + statusText: 'OK', + headers: [['content-type', 'text/event-stream; charset=utf-8']], + mimeType: 'TEXT/EVENT-STREAM', + }, + }, + { + sequence: 3, + monotonicMs: 3, + topic: 'fetch/response-body-chunk', + payload: { requestId: localId, data: Buffer.from(first).toString('base64') }, + }, + { + sequence: 4, + monotonicMs: 4, + topic: 'fetch/response-body-chunk', + payload: { requestId: localId, data: Buffer.from(second).toString('base64') }, + }, + { + sequence: 5, + monotonicMs: 5, + topic: 'fetch/end', + payload: { requestId: localId, capturedBytes: first.length + second.length, responseBodyTruncated: false }, + }, + ] +} + function requestId(localId: string): string { return `${source.sourceId}:${source.generation}:${localId}` } diff --git a/packages/experimental/inspector/tsconfig.client.json b/packages/experimental/inspector/tsconfig.client.json index 0c5a2b1f72..b24fdcb91f 100644 --- a/packages/experimental/inspector/tsconfig.client.json +++ b/packages/experimental/inspector/tsconfig.client.json @@ -79,6 +79,7 @@ "src/shared/identity.ts", "src/shared/index.ts", "src/shared/json.ts", + "src/shared/network/event-source.ts", "src/shared/network/observation.ts", "src/shared/service.ts", "src/shared/validation.ts" diff --git a/packages/experimental/inspector/tsconfig.host.json b/packages/experimental/inspector/tsconfig.host.json index 45c311d863..9ba0233518 100644 --- a/packages/experimental/inspector/tsconfig.host.json +++ b/packages/experimental/inspector/tsconfig.host.json @@ -81,6 +81,7 @@ "src/shared/identity.ts", "src/shared/index.ts", "src/shared/json.ts", + "src/shared/network/event-source.ts", "src/shared/network/observation.ts", "src/shared/service.ts", "src/shared/validation.ts", From 5a5d5de948f8689f5758a2d826ac03fa9debc1b2 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 06:47:09 +0800 Subject: [PATCH 073/130] fix(inspector): address protocol review findings --- ...-08-23-cross-realm-cdp-inspector.i18n.yaml | 4 +- .../2026-08-23-cross-realm-cdp-inspector.md | 4 +- ...2026-08-23-cross-realm-cdp-inspector.zh.md | 4 +- ...ution-realms-and-protocol-planes.i18n.yaml | 6 +- ...or-execution-realms-and-protocol-planes.md | 44 ++- ...execution-realms-and-protocol-planes.zh.md | 44 ++- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 2 +- packages/experimental/inspector/README.zh.md | 2 +- .../inspector/src/client/bridge/dispatcher.ts | 22 +- .../inspector/src/client/bridge/transport.ts | 62 +++- .../inspector/src/client/cdp/runtime.ts | 114 ++++++-- .../inspector/src/client/plugin.ts | 41 ++- .../inspector/src/host/bridge/dispatcher.ts | 15 +- .../inspector/src/host/bridge/publisher.ts | 31 +- .../inspector/src/host/bridge/transport.ts | 1 + .../inspector/src/host/inspection/network.ts | 8 + .../experimental/inspector/src/host/plugin.ts | 2 +- .../inspector/src/shared/bridge/buffer.ts | 16 +- .../src/shared/bridge/messages/observation.ts | 28 ++ .../shared/bridge/messages/runtime/frames.ts | 62 ++++ .../inspector/src/shared/cdp/operations.ts | 7 + .../inspector/src/shared/cdp/realm.ts | 9 +- .../inspector/src/shared/cordis/collector.ts | 21 +- .../inspector/src/shared/cordis/model.ts | 3 +- .../inspector/src/shared/cordis/projector.ts | 20 +- .../inspector/src/worker/bridge/hub.ts | 7 + .../src/worker/bridge/runtime-rpc.ts | 58 +++- .../worker/cdp/domains/runtime/cdp-params.ts | 2 +- .../src/worker/cdp/domains/runtime/session.ts | 23 +- .../src/worker/inspection/network-store.ts | 35 +-- .../src/worker/realms/client/runtime.ts | 13 +- .../src/worker/realms/host/runtime.ts | 26 +- .../tests/client-runtime.client.spec.ts | 49 ++++ .../inspector/tests/cordis-model.host.spec.ts | 225 +++++++++++++++ .../inspector/tests/cordis-tree.host.spec.ts | 102 +++++++ .../tests/fetch-observer.host.spec.ts | 265 +++++++++++++++++- .../inspector/tests/integration.host.spec.ts | 59 +++- .../inspector/tests/network.host.spec.ts | 196 +++++++++++++ .../inspector/tests/plugin.client.spec.ts | 71 +++++ .../inspector/tests/plugin.host.spec.ts | 2 +- .../inspector/tests/protocol.host.spec.ts | 9 + .../tests/shared-validation.host.spec.ts | 78 ++++++ .../tests/source-buffer.host.spec.ts | 122 +++++++- vitest.config.ts | 26 +- 45 files changed, 1753 insertions(+), 191 deletions(-) rename .agents/notes/{proposed => implemented}/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml (51%) rename .agents/notes/{proposed => implemented}/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md (69%) rename .agents/notes/{proposed => implemented}/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md (72%) create mode 100644 packages/experimental/inspector/tests/cordis-model.host.spec.ts create mode 100644 packages/experimental/inspector/tests/shared-validation.host.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml index 505887522c..47b37fad16 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.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-23-cross-realm-cdp-inspector.md -2026-08-23-cross-realm-cdp-inspector.md: 2b4860a84982b0ed6bf749c6b408dbda9a16416e -2026-08-23-cross-realm-cdp-inspector.zh.md: 64e52fe9d57d7c30e9c16358f2b1b7345f815849 +2026-08-23-cross-realm-cdp-inspector.md: e6e1d48da4f7b2dfab2772e24cb49711e3e50f5a +2026-08-23-cross-realm-cdp-inspector.zh.md: 9d67868caf9b4e5e0eb06583facd608b71468b22 diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md index 2b4860a849..e6e1d48da4 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md @@ -28,7 +28,7 @@ Chrome DevTools consumes one page-type target. Runtime methods route by executio Both MessagePort and WebSocket carriers use the same JSON value set and discriminated frames. A source identifies one logical producer and one connection generation, declares capabilities and topics, sends an initial replacement, then appends sequence-numbered batches. The Worker rejects malformed, oversized, stale-generation, and undeclared-topic frames before reading domain fields. -Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects. +Delivery is ordered and best-effort. Producers never wait for an acknowledgement on an application path. A bounded producer queue reports dropped prefixes through sequence gaps; the Host MessagePort carrier permits one append batch in flight and sends the next after the Worker acknowledges consumption. The Worker requests a new snapshot after an unexplained gap. Domain stores retain bounded state and explicitly close unfinished operations when a source disconnects. Runtime frames use closed command and result unions instead of method strings with untyped parameter records. Every request carries a source id, source generation, DevTools Runtime session id, request id, and command. Every result repeats those identities and the command discriminant. Console lifecycle/events, chunked source reads, and non-CDP semantic queries have separate correlated frame families. RemoteObject values, previews, property descriptors, call arguments, exceptions, Console events, debugger frames, scripts, and errors have dedicated exact decoders. @@ -38,7 +38,7 @@ Runtime frames use closed command and result unions instead of method strings wi The Client Runtime subset covers `Runtime.evaluate`, `Runtime.getProperties`, `Runtime.callFunctionOn`, `Runtime.awaitPromise`, `Runtime.releaseObject`, `Runtime.releaseObjectGroup`, and `Runtime.globalLexicalScopeNames`. The Client executes commands in its page realm and retains live objects in a table isolated by DevTools Runtime session. It returns opaque handles and JSON-safe metadata; the Worker validates the result and assigns a connection-local CDP object id. An object argument may be used only by the same Client source generation and DevTools session. Closing the source, disabling Runtime, closing DevTools, releasing an object, or releasing an object group removes the corresponding handles. -JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state. +JavaScript exceptions are successful Runtime responses carrying `exceptionDetails`; transport failures use a separate error union. A Worker deadline sends request-scoped cancellation to the Client. Handles allocated for a response remain provisional until the Worker acknowledges that response, so cancellation and late responses cannot leave unreachable objects. Finite command deadlines, object counts, property counts, source bytes, and frame bytes bound retained or returned state. The Client Console observer preserves the original page call and asynchronously emits one event per enabled DevTools session. Each session serializes arguments into its own `console` object group, so disconnect, Runtime disable, or `Runtime.discardConsoleEntries` can release one connection without invalidating another. Context and Fiber arguments use the same semantic reference and DOM reverse mapping as evaluation results. diff --git a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md index 64e52fe9d5..9d67868caf 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md @@ -28,7 +28,7 @@ Chrome DevTools 消费一个 page 类型 target。Runtime 方法按 execution co MessagePort 与 WebSocket carrier 使用同一组 JSON 值和判别联合帧。source 标识一个逻辑 producer 和一个连接 generation,声明 capability 与 topic,发送初始 replace,再追加带 sequence 的 batch。Worker 在读取 domain 字段前拒绝畸形、超限、旧 generation 和未声明 topic 的帧。 -投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。 +投递有序且尽力而为。producer 不在应用路径上等待 acknowledgement。有界 producer 队列通过 sequence gap 报告被丢弃的前缀;Host MessagePort carrier 同时只允许一个 append batch 在途,并在 Worker 确认消费后发送下一批。无法解释的 gap 会让 Worker 请求新 snapshot。domain store 只保留有界状态,并在 source 断开时明确关闭未完成操作。 Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符串加无类型 parameter record。每个 request 携带 source id、source generation、DevTools Runtime session id、request id 和 command;每个 result 重复这些身份与 command 判别符。Console lifecycle/event、分块 source 读取和非 CDP 语义查询使用各自独立的关联帧。RemoteObject value、preview、property descriptor、call argument、exception、Console event、debugger frame、script 与 error 都有独立的精确 decoder。 @@ -38,7 +38,7 @@ Runtime 帧使用封闭的 command 与 result 联合,而不是 method 字符 Client Runtime 子集包括 `Runtime.evaluate`、`Runtime.getProperties`、`Runtime.callFunctionOn`、`Runtime.awaitPromise`、`Runtime.releaseObject`、`Runtime.releaseObjectGroup` 和 `Runtime.globalLexicalScopeNames`。Client 在页面 realm 中执行命令,并在按 DevTools Runtime session 隔离的表中保留实时对象。Client 只返回不透明 handle 与 JSON-safe metadata;Worker 验证结果并分配连接私有的 CDP object id。对象参数只能由同一 Client source generation 与 DevTools session 使用。source 断开、Runtime disable、DevTools 关闭、释放对象或释放 object group 都会移除对应 handle。 -JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。 +JavaScript exception 是携带 `exceptionDetails` 的成功 Runtime response;transport failure 使用独立的 error 联合。Worker deadline 会向 Client 发送 request-scoped cancellation。response 分配的 handle 在 Worker 确认该 response 前保持 provisional,因此 cancellation 和 late response 不会留下无法访问的对象。有限的命令 deadline、对象数、属性数、source 字节数与帧字节数约束保留或返回的状态。 Client Console observer 保持原始页面调用行为,并为每个已启用的 DevTools session 异步发出一份 event。每个 session 把 argument 序列化到自己的 `console` object group,因此断联、Runtime disable 或 `Runtime.discardConsoleEntries` 可以释放一条连接而不使其他连接失效。Context 与 Fiber argument 使用和求值结果相同的语义引用及 DOM 反向映射。 diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml similarity index 51% rename from .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml index 31a09c0e9a..b175565b1d 100644 --- a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md -2026-08-26-inspector-execution-realms-and-protocol-planes.md: 67f37add1a788a450643a362fac24426ff80a43e -2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: be4dd947fd999d0b9c09f7b4c821390b5b3b1245 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md +2026-08-26-inspector-execution-realms-and-protocol-planes.md: e8bff0661d2d0c86c216b0a18e2feb7a2c786709 +2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md: 2db6307d1bfc536ac5e8b0f5d6f03e4cfe334989 diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md similarity index 69% rename from .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md rename to .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md index 67f37add1a..e8bff0661d 100644 --- a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.md @@ -1,20 +1,20 @@ # Agent Note: Inspector execution realms and protocol planes -Status: proposed +Status: implemented English | [中文](2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md) ## Problem -The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Its current source tree mixes execution ownership with feature names: browser and Host producers have unrelated layouts, Worker-executed Client and Host backends sit under a generic backend directory, and one protocol directory combines transport frames, Cordis data, network observations, and CDP-oriented Runtime values. A file path therefore does not establish where code runs or which identifiers it may own. +The Inspector package executes code in three JavaScript environments: the browser Client, the Host Node main thread, and an Inspector Worker thread. Without execution-oriented directories, feature names alone do not establish where code runs or which identifiers it may own. This ambiguity is risky because Host and Client support intentionally differs while their architecture must remain comparable. Host Runtime and Debugger delegate to Node's inspector protocol; Client Runtime and Console simulate the same backend semantics over an internal bridge. If their files, interfaces, and unsupported operations diverge structurally, each new protocol method encourages a second routing model. Likewise, consumers that only need the Cordis runtime tree must not inherit debugger activation, Chrome connection state, or CDP identifiers. -The [cross-realm CDP inspector decision](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.md) continues to own Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](../../implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md) continues to own Cordis tree semantics, object routing, and DOM projection. This proposal owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state. +The [cross-realm CDP inspector decision](2026-08-23-cross-realm-cdp-inspector.md) owns Worker, transport, Runtime, debugger, and security behavior. The [Cordis runtime tree inspection decision](2026-08-24-cordis-runtime-tree-inspection.md) owns Cordis tree semantics, object routing, and DOM projection. This decision owns source placement, dependency direction, and the separation between domain data, backend semantics, internal transport, and Chrome CDP state. -## Proposal +## Decision -Top-level source directories will identify execution ownership. `client/` will contain only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client still belongs under `worker/`, not `client/`. +Top-level source directories identify execution ownership. `client/` contains only browser Client code, `host/` only Host Node-main-thread code, `worker/` only Worker-thread code, and `shared/` code that is safe in every environment. A module that executes in the Worker on behalf of a Client belongs under `worker/`, not `client/`. The repository-required `src/index.ts` and `src/invariant.ts` discovery entries are the only root-level source exceptions. They expose the Host package entry and its service type or register the invariant companion, contain no Inspector runtime implementation, and remain at fixed paths for repository tooling. @@ -26,9 +26,9 @@ src/ worker/ Worker transport, repositories, realm backends, and CDP endpoint ``` -`client/` and `host/` will have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support. +`client/` and `host/` have the same relative directories and filenames. Their common roles are plugin entry, bridge lifecycle and RPC, Cordis and network inspection, and CDP-oriented Runtime, Console, Debugger, Sources, Profiler, and HeapProfiler adapters. Support may differ: an unavailable operation remains in the corresponding mirrored module and returns the shared capability-unavailable or typed-unsupported result. Mirroring standardizes where a capability is implemented; it does not claim equal engine support. -Worker-side realm adapters will use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers. +Worker-side realm adapters use the same rule under `worker/realms/client/` and `worker/realms/host/`. These adapters normalize Client simulation and Node inspector behavior behind shared CDP-oriented backend interfaces. They do not own Chrome wire messages or connection-local CDP identifiers. ## Execution ownership @@ -62,13 +62,16 @@ Top-level `client/` and `host/` import `shared/` but never each other or `worker The package remains one `@deepseek-ai/dsh-experimental-inspector` package with explicit Client and Host compiler faces. Directory separation is an execution and dependency rule, not a package split. -## Migration order +## Verification -First, the current Cordis implementation and its semantic types move into `shared/cordis/`. The current protocol directory then separates into `shared/bridge/`, `shared/cdp/`, and `shared/network/` while preserving validated frame discriminants and limits. - -Next, top-level Client and Host files move into exact mirrored paths. Missing support is represented explicitly so the mirrors remain complete. Worker Client and Node backends then move to mirrored `worker/realms/client/` and `worker/realms/host/` directories, with Node renamed to Host at the architectural interface. - -Finally, source transport and routing move under `worker/bridge/`, Cordis, network, and query repositories under `worker/inspection/`, and Chrome endpoint and domains under `worker/cdp/`. Imports, explicit compiler-face file lists, package entries, and focused tests change with each owning layer. The final tree has no generic top-level `protocol/` or `cordis/` directory and no generic `worker/backends/` directory. +- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root. +- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed. +- Cordis and network readers are usable without importing debugger, source, transport, or CDP session modules. +- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids. +- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages. +- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state. +- Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior have focused coverage. +- Compiler faces, import checks, and the structural layout test reject environment leaks and Client/Host mirror drift. ## Alternatives considered @@ -82,18 +85,7 @@ Finally, source transport and routing move under `worker/bridge/`, Cordis, netwo **Split Client, Host, protocol, and Worker into separate packages.** Rejected for the experimental phase. The deployment unit remains one Client/Host Cordis plugin, and package boundaries would add build and release coordination without improving the required execution separation. -## Acceptance criteria - -- Every runtime implementation has an unambiguous execution owner through `shared/`, `client/`, `host/`, or `worker/`; only the repository-required package and invariant forwarding entries remain at the source root. -- Top-level Client and Host trees, and Worker Client and Host realm trees, have identical relative implementation paths; unequal capability support is explicit and typed. -- Cordis and network readers can be used without importing debugger, source, transport, or CDP session modules. -- Internal messages contain source-level identities and validated domain values but no Chrome connection-local ids. -- Normalized realm backend interfaces support Host delegation and Client simulation without either implementation constructing Chrome CDP messages. -- Only Worker CDP modules allocate Chrome ids and own DevTools connection enable, object, script, node, and call-frame state. -- Existing Host Runtime and debugging, Client Runtime and Console, Network capture, Cordis Elements projection, disconnect retention, and semantic query behavior remain covered after the move. -- Compiler faces, import checks, and a structural test reject environment leaks and Client/Host mirror drift. - -## Risks +## Consequences Exact mirroring adds small adapter files for unsupported capabilities. Those files are intentional compatibility points between implementations, but they must stay thin and must not manufacture fake behavior. @@ -101,4 +93,4 @@ Moving types without changing behavior can still expose hidden dependency cycles `shared/cdp/` can become a second copy of the Chrome protocol if normalized types are added indiscriminately. A shared type belongs there only when both realm implementations or a common Worker projector consume it; Chrome session bookkeeping and wire-only fields remain under `worker/cdp/`. -The migration may temporarily leave the package uncompilable between local move steps. The completed change must restore both compiler faces and behavior tests before review. +Explicit Client and Host compiler faces and focused behavior tests add maintenance work, but they keep environment leaks and mirror drift visible. diff --git a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md similarity index 72% rename from .agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md rename to .agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md index be4dd947fd..2db6307d1b 100644 --- a/.agents/notes/proposed/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-26-inspector-execution-realms-and-protocol-planes.zh.md @@ -1,20 +1,20 @@ # Agent Note: Inspector 执行环境与协议平面 -Status: proposed +Status: implemented [English](2026-08-26-inspector-execution-realms-and-protocol-planes.md) | 中文 ## Problem -Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。当前源码树把执行归属与功能名称混在一起:浏览器和 Host producer 使用互不对应的目录结构,代表 Client 与 Host 的 Worker 代码位于笼统的 backend 目录,而一个 protocol 目录同时包含 transport frame、Cordis 数据、network observation 与面向 CDP 的 Runtime value。因此,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。 +Inspector 包的代码运行在三个 JavaScript 环境中:浏览器 Client、Host Node 主线程和 Inspector Worker thread。只按功能命名目录时,文件路径无法说明代码在哪里运行,也无法说明它可以持有哪些标识符。 这种含糊会带来风险,因为 Host 与 Client 的支持能力有意不同,但架构必须保持可比较。Host Runtime 与 Debugger 委托 Node inspector protocol;Client Runtime 与 Console 通过内部 bridge 模拟同一套 backend 语义。如果两边的文件、接口与 unsupported operation 在结构上分叉,每增加一种协议方法都容易产生第二套路由模型。同样,只需要 Cordis 运行时树的消费方不应继承 debugger activation、Chrome 连接状态或 CDP 标识符。 -现有的[跨 realm CDP Inspector 决策](../../implemented/architecture/2026-08-23-cross-realm-cdp-inspector.zh.md)继续负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](../../implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md)继续负责 Cordis 树语义、对象路由与 DOM projection。本提案只负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。 +现有的[跨 realm CDP Inspector 决策](2026-08-23-cross-realm-cdp-inspector.zh.md)负责 Worker、transport、Runtime、debugger 与安全行为。[Cordis 运行时树检查决策](2026-08-24-cordis-runtime-tree-inspection.zh.md)负责 Cordis 树语义、对象路由与 DOM projection。本决策负责源码位置、依赖方向,以及领域数据、backend 语义、内部 transport 和 Chrome CDP 状态之间的分隔。 -## Proposal +## Decision -顶层源码目录将标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。 +顶层源码目录标识执行归属。`client/` 只包含浏览器 Client 代码,`host/` 只包含 Host Node 主线程代码,`worker/` 只包含 Worker thread 代码,`shared/` 只包含在所有环境中都安全的代码。即使某个模块代表 Client,只要它实际在 Worker 中执行,就仍属于 `worker/`,而不是 `client/`。 仓库要求的 `src/index.ts` 与 `src/invariant.ts` 发现入口是仅有的源码根目录例外。它们暴露 Host package entry 及其 service type,或注册 invariant companion,不包含 Inspector 运行时实现,并为仓库工具保留在固定路径。 @@ -26,9 +26,9 @@ src/ worker/ Worker transport, repositories, realm backends, and CDP endpoint ``` -`client/` 与 `host/` 将拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。 +`client/` 与 `host/` 拥有相同的相对目录和文件名。共同角色包括 plugin entry、bridge lifecycle 与 RPC、Cordis 和 network inspection,以及面向 CDP 的 Runtime、Console、Debugger、Sources、Profiler 和 HeapProfiler adapter。支持程度可以不同:不可用的操作仍保留在对应的镜像模块中,并返回共享的 capability-unavailable 或类型化 unsupported 结果。镜像结构统一的是能力实现位置,而不是宣称两个引擎支持相同功能。 -Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口,规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。 +Worker 侧 realm adapter 在 `worker/realms/client/` 与 `worker/realms/host/` 下遵守相同规则。这些 adapter 通过共享的面向 CDP backend 接口规范化 Client 模拟行为与 Node inspector 行为。它们不拥有 Chrome wire message 或连接局部的 CDP 标识符。 ## Execution ownership @@ -62,13 +62,16 @@ Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模 本能力继续保留在同一个 `@deepseek-ai/dsh-experimental-inspector` 包中,并使用显式 Client 与 Host compiler face。目录分隔是执行与依赖规则,不是拆包方案。 -## Migration order +## Verification -首先把现有 Cordis 实现及其语义类型移动到 `shared/cordis/`。随后把当前 protocol 目录拆成 `shared/bridge/`、`shared/cdp/` 与 `shared/network/`,同时保留已验证的 frame discriminant 与限制。 - -接下来把顶层 Client 与 Host 文件移动到严格镜像的路径。缺失支持使用显式表示,使镜像保持完整。然后把 Worker Client 与 Node backend 移动到镜像的 `worker/realms/client/` 和 `worker/realms/host/` 目录,并在架构接口上把 Node 命名统一为 Host。 - -最后把 source transport 与 routing 移到 `worker/bridge/`,Cordis、network 与 query repository 移到 `worker/inspection/`,Chrome endpoint 与 domain 移到 `worker/cdp/`。imports、显式 compiler-face file list、package entry 与聚焦测试随所属层一起修改。最终源码树不保留笼统的顶层 `protocol/`、`cordis/` 或 `worker/backends/` 目录。 +- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。 +- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。 +- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。 +- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。 +- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。 +- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。 +- Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为均有聚焦测试覆盖。 +- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。 ## Alternatives considered @@ -82,18 +85,7 @@ Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模 **把 Client、Host、protocol 与 Worker 拆成多个包。** 实验阶段拒绝。部署单元仍是一个 Client/Host Cordis plugin;包边界会增加构建和发布协作,却不能改善所需的执行环境分隔。 -## Acceptance criteria - -- 每个运行时实现都通过 `shared/`、`client/`、`host/` 或 `worker/` 拥有明确的执行 owner;只有仓库要求的 package 与 invariant 转发入口留在源码根目录。 -- 顶层 Client/Host 树与 Worker Client/Host realm 树分别拥有相同的相对实现路径;不同能力支持使用显式类型表示。 -- Cordis 与 network reader 无需导入 debugger、source、transport 或 CDP session 模块即可使用。 -- 内部 message 包含 source 层 identity 与已验证领域值,但不包含 Chrome 连接局部 id。 -- 规范化 realm backend interface 同时支持 Host 委托与 Client 模拟,且两种实现都不构造 Chrome CDP message。 -- 只有 Worker CDP 模块分配 Chrome id,并持有 DevTools 连接的 enable、object、script、node 与 call-frame 状态。 -- 移动后继续覆盖现有 Host Runtime 与 debugging、Client Runtime 与 Console、Network capture、Cordis Elements projection、断联保留与语义 query 行为。 -- compiler face、import check 与结构测试能够拒绝环境泄漏和 Client/Host 镜像漂移。 - -## Risks +## Consequences 严格镜像会为不支持的能力增加小型 adapter 文件。这些文件是两个实现之间有意保留的兼容点,但必须保持轻薄,也不能制造虚假行为。 @@ -101,4 +93,4 @@ Worker 继续作为唯一的 Chrome CDP wire 与状态 owner。Client 代码模 如果不加约束地添加规范化类型,`shared/cdp/` 可能变成第二份 Chrome protocol。只有两个 realm 实现或公共 Worker projector 会消费的类型才属于这里;Chrome session bookkeeping 与 wire-only field 保留在 `worker/cdp/`。 -本地迁移步骤之间可能暂时无法编译。完整变更必须在 review 前恢复两个 compiler face 与行为测试。 +显式 Client/Host compiler face 与聚焦行为测试增加了维护工作,但会持续暴露环境泄漏和镜像结构漂移。 diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 6925e0ae2b..69720dbcc8 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md -README.md: 86357b3a91763571ffe0cf9eea389b2b21c84d77 -README.zh.md: ce030fa3873f4cd488855d5c963a27eac0df9c8e +README.md: 09f8b2901e8a8d5c6e8264d2e1be680d3b968178 +README.zh.md: 510ca42362a3c7e779a672b721ec9bc43ede07b5 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 86357b3a91..09f8b2901e 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -111,7 +111,7 @@ Fetch capture is on by default and records the complete URL, all request and res The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer. -After response headers arrive, a caller-side abort ends clone capture as a retained, possibly truncated response rather than a failed request. A fetch rejection before response headers remains a failed request. +After response headers arrive, bytes captured before a caller-side abort remain available through `Network.getResponseBody`, while the request ends with `Network.loadingFailed { canceled: true }`. A fetch rejection before response headers follows the same canceled failure path. ## Security diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index ce030fa387..510ca42362 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -111,7 +111,7 @@ fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、 配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。 -response headers 到达后,调用方 abort 会结束 clone 采集,并保留一份可能 truncated 的 response,而不会把整个请求标记为失败。response headers 到达前发生的 fetch rejection 仍然是失败请求。 +response headers 到达后,如果调用方 abort,已采集的字节仍可通过 `Network.getResponseBody` 读取,同时请求以 `Network.loadingFailed { canceled: true }` 结束。response headers 到达前发生的 fetch rejection 也走相同的取消失败路径。 ## 安全 diff --git a/packages/experimental/inspector/src/client/bridge/dispatcher.ts b/packages/experimental/inspector/src/client/bridge/dispatcher.ts index 535a8ba237..2f1ca54e44 100644 --- a/packages/experimental/inspector/src/client/bridge/dispatcher.ts +++ b/packages/experimental/inspector/src/client/bridge/dispatcher.ts @@ -3,18 +3,29 @@ import type { ClientConsoleDisableFrame, ClientConsoleEnableFrame, + ClientRuntimeCancelFrame, ClientRuntimeRequestFrame, + ClientRuntimeResponseAcknowledgedFrame, ClientRuntimeSessionClosedFrame, } from '../../shared/bridge/messages/runtime/index.ts' import type { ClientSourceRequestFrame, ClientSourceSessionClosedFrame } from '../../shared/bridge/messages/sources/index.ts' -import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' +import type { + SourceAcceptedFrame, + SourceAppendAcknowledgedFrame, + SourceRejectedFrame, + SourceResnapshotFrame, + WorkerToSourceFrame, +} from '../../shared/bridge/messages/observation.ts' /** Operations invoked for each Worker-to-Client frame family. */ export interface ClientBridgeFrameHandlers { accepted(frame: SourceAcceptedFrame): void + acknowledged(frame: SourceAppendAcknowledgedFrame): void resnapshot(frame: SourceResnapshotFrame): void rejected(frame: SourceRejectedFrame): void runtime(frame: ClientRuntimeRequestFrame): void + runtimeCanceled(frame: ClientRuntimeCancelFrame): void + runtimeAcknowledged(frame: ClientRuntimeResponseAcknowledgedFrame): void runtimeClosed(frame: ClientRuntimeSessionClosedFrame): void consoleEnabled(frame: ClientConsoleEnableFrame): void consoleDisabled(frame: ClientConsoleDisableFrame): void @@ -32,6 +43,9 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: Client case 'source/accepted': handlers.accepted(frame) return + case 'source/append-acknowledged': + handlers.acknowledged(frame) + return case 'source/resnapshot': handlers.resnapshot(frame) return @@ -41,6 +55,12 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: Client case 'client-runtime/request': handlers.runtime(frame) return + case 'client-runtime/cancel': + handlers.runtimeCanceled(frame) + return + case 'client-runtime/response-acknowledged': + handlers.runtimeAcknowledged(frame) + return case 'client-runtime/session-closed': handlers.runtimeClosed(frame) return diff --git a/packages/experimental/inspector/src/client/bridge/transport.ts b/packages/experimental/inspector/src/client/bridge/transport.ts index 90ea7164f0..68b7c0cc02 100644 --- a/packages/experimental/inspector/src/client/bridge/transport.ts +++ b/packages/experimental/inspector/src/client/bridge/transport.ts @@ -1,7 +1,11 @@ /** Client observation and Runtime endpoint over the Inspector Worker's ingest WebSocket. */ import type { InspectorClientBootstrap } from '../../shared/bridge/messages/control.ts' -import type { InspectorSourceGeneration } from '../../shared/bridge/ids.ts' +import type { + ClientRuntimeRequestId, + ClientRuntimeSessionId, + InspectorSourceGeneration, +} from '../../shared/bridge/ids.ts' import { isJsonValue, jsonByteLength } from '../../shared/json.ts' import { INSPECTOR_PROTOCOL_VERSION, @@ -34,6 +38,10 @@ export class ClientInspectorSource extends InspectorSourceConnection { private accepted = false private closed = false private readonly runtime: ClientRuntimeExecutor + private readonly runtimeRequests = new Map() private readonly console: ClientConsoleObserver protected readonly queries: ClientBridgeRpc private readonly lifecycle: ClientBridgeLifecycle @@ -92,6 +100,7 @@ export class ClientInspectorSource extends InspectorSourceConnection { if (this.closed) return this.closed = true this.console.close() + this.cancelRuntimeRequests() this.runtime.reset() this.queries.close('Inspector Client source closed') this.lifecycle.close() @@ -116,6 +125,7 @@ export class ClientInspectorSource extends InspectorSourceConnection { private connect(): void { if (this.closed) return this.console.reset() + this.cancelRuntimeRequests() this.runtime.reset() this.queries.disconnect('Inspector Client source reconnecting') const source = this.realmSource.connect(this.sourceCatalog !== undefined) @@ -153,6 +163,7 @@ export class ClientInspectorSource extends InspectorSourceConnection { this.queries.connectSocket(source, socket) this.publisher.accept(socket) }, + acknowledged: () => {}, resnapshot: () => { this.publisher.replace(socket) }, rejected: (rejected) => { console.error(`[inspector] Client source rejected: ${rejected.message}`) @@ -164,7 +175,12 @@ export class ClientInspectorSource extends InspectorSourceConnection { socket.close(1011, 'Client Runtime transport failed') }) }, + runtimeCanceled: (canceled) => { this.cancelRuntime(canceled.sessionId, canceled.requestId) }, + runtimeAcknowledged: (acknowledged) => { + this.acknowledgeRuntime(acknowledged.sessionId, acknowledged.requestId) + }, runtimeClosed: (closed) => { + this.cancelRuntimeSession(closed.sessionId) this.console.disable(closed.sessionId) this.runtime.closeSession(closed.sessionId) }, @@ -189,6 +205,7 @@ export class ClientInspectorSource extends InspectorSourceConnection { this.accepted = false this.publisher.disconnect(socket) this.console.reset() + this.cancelRuntimeRequests() this.runtime.reset() this.queries.disconnect('Inspector Client source disconnected') this.lifecycle.reconnect(() => { this.connect() }) @@ -203,11 +220,50 @@ export class ClientInspectorSource extends InspectorSourceConnection { generation: InspectorSourceGeneration, frame: Extract, { t: 'client-runtime/request' }>, ): Promise { - const response = await this.runtime.execute(frame) - if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) return + const controller = new AbortController() + const operation = { controller, sessionId: frame.sessionId } + this.runtimeRequests.set(frame.requestId, operation) + const response = await this.runtime.execute(frame, controller.signal, true) + if (this.runtimeRequests.get(frame.requestId) !== operation) return + if (this.closed || this.socket !== socket || this.generation !== generation || socket.readyState !== WebSocket.OPEN) { + this.cancelRuntime(frame.sessionId, frame.requestId) + return + } socket.send(JSON.stringify(response)) } + private acknowledgeRuntime(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void { + const operation = this.runtimeRequests.get(requestId) + if (operation === undefined || operation.sessionId !== sessionId) return + this.runtimeRequests.delete(requestId) + this.runtime.acknowledge(sessionId, requestId) + } + + private cancelRuntime(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void { + const operation = this.runtimeRequests.get(requestId) + if (operation === undefined || operation.sessionId !== sessionId) return + this.runtimeRequests.delete(requestId) + operation.controller.abort() + this.runtime.cancel(sessionId, requestId) + } + + private cancelRuntimeSession(sessionId: ClientRuntimeSessionId): void { + for (const [requestId, operation] of this.runtimeRequests) { + if (operation.sessionId !== sessionId) continue + operation.controller.abort() + this.runtime.cancel(sessionId, requestId) + this.runtimeRequests.delete(requestId) + } + } + + private cancelRuntimeRequests(): void { + for (const [requestId, operation] of this.runtimeRequests) { + operation.controller.abort() + this.runtime.cancel(operation.sessionId, requestId) + } + this.runtimeRequests.clear() + } + private async executeSourceRequest( socket: WebSocket, generation: InspectorSourceGeneration, diff --git a/packages/experimental/inspector/src/client/cdp/runtime.ts b/packages/experimental/inspector/src/client/cdp/runtime.ts index b72a73e2fc..b7ddbac384 100644 --- a/packages/experimental/inspector/src/client/cdp/runtime.ts +++ b/packages/experimental/inspector/src/client/cdp/runtime.ts @@ -12,7 +12,11 @@ import type { ClientRuntimeResult, ClientRuntimeRemoteObject, } from '../../shared/bridge/messages/runtime/index.ts' -import type { ClientRemoteObjectHandle, ClientRuntimeSessionId } from '../../shared/bridge/ids.ts' +import type { + ClientRemoteObjectHandle, + ClientRuntimeRequestId, + ClientRuntimeSessionId, +} from '../../shared/bridge/ids.ts' import { isJsonValue, jsonByteLength } from '../../shared/json.ts' import { INSPECTOR_PROTOCOL_VERSION } from '../../shared/bridge/version.ts' import { ClientRuntimeExecutionError } from './errors.ts' @@ -42,6 +46,11 @@ export interface ClientRuntimeLimits { /** Executes Runtime requests while isolating object handles by DevTools session. */ export class ClientRuntimeExecutor { private readonly sessions = new Map() + private readonly responseAllocations = new Map() constructor( private readonly limits: ClientRuntimeLimits, @@ -51,13 +60,22 @@ export class ClientRuntimeExecutor { /** * Execute one request and preserve its source, generation, session, and request identities. * @param frame - Validated command envelope from the Worker. + * @param signal - Optional cancellation for an operation awaiting user code. + * @param deferObjectCommit - Keep new object handles provisional until {@link acknowledge}. * @returns A success or transport-error response for the same request. */ - async execute(frame: ClientRuntimeRequestFrame): Promise { + async execute( + frame: ClientRuntimeRequestFrame, + signal?: AbortSignal, + deferObjectCommit = false, + ): Promise { const session = this.session(frame.sessionId) const allocation = session.beginAllocation() try { - const result = await session.execute(frame.command, allocation) + const result = await session.execute(frame.command, allocation, signal) + if (signal?.aborted === true) { + throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled') + } const response = responseFrame(frame, { ok: true, result }) if (!isJsonValue(response) || jsonByteLength(response) > this.limits.maxResponseBytes) { session.rollback(allocation) @@ -66,7 +84,18 @@ export class ClientRuntimeExecutor { error: { code: 'result-too-large', message: 'Client Runtime result exceeds the source-frame byte limit' }, }) } - session.commitAllocation(allocation) + if (deferObjectCommit) { + if (this.responseAllocations.has(frame.requestId)) { + session.rollback(allocation) + return responseFrame(frame, { + ok: false, + error: { code: 'invalid-request', message: 'Client Runtime request id is already pending' }, + }) + } + this.responseAllocations.set(frame.requestId, { sessionId: frame.sessionId, session, allocation }) + } else { + session.commitAllocation(allocation) + } return response } catch (error) { session.rollback(allocation) @@ -74,11 +103,38 @@ export class ClientRuntimeExecutor { } } + /** + * Commit handles after the Worker accepts one Runtime response. + * @param sessionId - Session that owns the response. + * @param requestId - Correlation id acknowledged by the Worker. + */ + acknowledge(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void { + const pending = this.responseAllocations.get(requestId) + if (pending === undefined || pending.sessionId !== sessionId) return + this.responseAllocations.delete(requestId) + pending.session.commitAllocation(pending.allocation) + } + + /** + * Roll back handles from a canceled or otherwise unaccepted Runtime response. + * @param sessionId - Session that owns the response. + * @param requestId - Correlation id rejected by the Worker. + */ + cancel(sessionId: ClientRuntimeSessionId, requestId: ClientRuntimeRequestId): void { + const pending = this.responseAllocations.get(requestId) + if (pending === undefined || pending.sessionId !== sessionId) return + this.responseAllocations.delete(requestId) + pending.session.rollback(pending.allocation) + } + /** * Release all values retained for one closed DevTools connection. * @param sessionId - Runtime session owned by that DevTools connection. */ closeSession(sessionId: ClientRuntimeSessionId): void { + for (const [requestId, pending] of this.responseAllocations) { + if (pending.sessionId === sessionId) this.responseAllocations.delete(requestId) + } this.sessions.get(sessionId)?.close() this.sessions.delete(sessionId) } @@ -170,6 +226,7 @@ export class ClientRuntimeExecutor { /** Release all sessions when a source generation ends or reconnects. */ reset(): void { + this.responseAllocations.clear() for (const session of this.sessions.values()) session.close() this.sessions.clear() } @@ -211,18 +268,22 @@ class ClientRuntimeSession { this.objects.rollback(allocation) } - async execute(command: ClientRuntimeCommand, allocation: ClientObjectAllocation): Promise { + async execute( + command: ClientRuntimeCommand, + allocation: ClientObjectAllocation, + signal?: AbortSignal, + ): Promise { switch (command.op) { case 'evaluate': - return { op: command.op, completion: await this.evaluate(command, allocation) } + return { op: command.op, completion: await this.evaluate(command, allocation, signal) } case 'get-properties': { const result = getClientProperties(this.objects, command, this.maxProperties, allocation) return { op: command.op, ...result } } case 'call-function': - return { op: command.op, completion: await this.callFunction(command, allocation) } + return { op: command.op, completion: await this.callFunction(command, allocation, signal) } case 'await-promise': - return { op: command.op, completion: await this.awaitPromise(command, allocation) } + return { op: command.op, completion: await this.awaitPromise(command, allocation, signal) } case 'release-object': this.objects.release(command.handle) return { op: command.op } @@ -274,11 +335,12 @@ class ClientRuntimeSession { private async evaluate( command: Extract, allocation: ClientObjectAllocation, + signal?: AbortSignal, ): Promise { let value: unknown try { value = globalThis.eval(command.expression) as unknown - if (command.awaitPromise === true) value = await awaitWithTimeout(value, command.timeoutMs) + if (command.awaitPromise === true) value = await awaitWithCancellation(value, signal, command.timeoutMs) } catch (error) { if (error instanceof ClientRuntimeExecutionError) throw error return this.exception(error, command.objectGroup, allocation) @@ -295,6 +357,7 @@ class ClientRuntimeSession { private async callFunction( command: Extract, allocation: ClientObjectAllocation, + signal?: AbortSignal, ): Promise { const receiver = command.receiver === undefined ? globalThis : this.objects.get(command.receiver) const inheritedGroup = command.receiver === undefined ? undefined : this.objects.group(command.receiver) @@ -305,8 +368,9 @@ class ClientRuntimeSession { const fn = globalThis.eval(`(${command.functionDeclaration}\n)`) as unknown if (typeof fn !== 'function') throw new TypeError('functionDeclaration did not evaluate to a function') value = Reflect.apply(fn, receiver, args) - if (command.awaitPromise === true) value = await value + if (command.awaitPromise === true) value = await awaitWithCancellation(value, signal) } catch (error) { + if (error instanceof ClientRuntimeExecutionError) throw error return this.exception(error, group, allocation) } return this.completion(value, allocation, group, command.generatePreview, command.returnByValue) @@ -315,11 +379,12 @@ class ClientRuntimeSession { private async awaitPromise( command: Extract, allocation: ClientObjectAllocation, + signal?: AbortSignal, ): Promise { const group = this.objects.group(command.promise) let value: unknown try { - value = await this.objects.get(command.promise) + value = await awaitWithCancellation(this.objects.get(command.promise), signal) } catch (error) { if (error instanceof ClientRuntimeExecutionError) throw error return this.exception(error, group, allocation) @@ -401,20 +466,33 @@ function clientUrl(): { readonly url?: string } { return typeof href === 'string' ? { url: href } : {} } -async function awaitWithTimeout(value: unknown, timeoutMs: number | undefined): Promise { - if (timeoutMs === undefined) return await value +async function awaitWithCancellation( + value: unknown, + signal: AbortSignal | undefined, + timeoutMs?: number, +): Promise { + if (signal?.aborted === true) throw new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled') let timer: ReturnType | undefined + let onAbort: (() => void) | undefined try { - return await Promise.race([ - Promise.resolve(value), - new Promise((_resolve, reject) => { + const limits: Promise[] = [] + if (timeoutMs !== undefined) { + limits.push(new Promise((_resolve, reject) => { timer = setTimeout(() => { reject(new ClientRuntimeExecutionError('timeout', `Client evaluation exceeded ${String(timeoutMs)}ms`)) }, timeoutMs) - }), - ]) + })) + } + if (signal !== undefined) { + limits.push(new Promise((_resolve, reject) => { + onAbort = () => { reject(new ClientRuntimeExecutionError('timeout', 'Client Runtime request was canceled')) } + signal.addEventListener('abort', onAbort, { once: true }) + })) + } + return await Promise.race([Promise.resolve(value), ...limits]) } finally { if (timer !== undefined) clearTimeout(timer) + if (onAbort !== undefined) signal?.removeEventListener('abort', onAbort) } } diff --git a/packages/experimental/inspector/src/client/plugin.ts b/packages/experimental/inspector/src/client/plugin.ts index 5b619a4788..d29ba5cdc0 100644 --- a/packages/experimental/inspector/src/client/plugin.ts +++ b/packages/experimental/inspector/src/client/plugin.ts @@ -47,15 +47,38 @@ export function apply(ctx: Context): void { const bootstrap = parseInspectorClientBootstrap(injected) ctx.effect(() => { const source = startInspectorClient(bootstrap) - const disposeCordis = publishCordisTree(ctx, source, { - maxNodes: bootstrap.maxCordisNodes, - maxBytes: bootstrap.maxFrameBytes - 4_096, - }) - const disposeService = ctx.provide('inspector', createInspectorService(source)) - return () => { - disposeService() - disposeCordis() - source.close() + const disposers: Array<() => unknown> = [] + try { + disposers.push(publishCordisTree(ctx, source, { + maxNodes: bootstrap.maxCordisNodes, + maxBytes: bootstrap.maxFrameBytes - 4_096, + })) + disposers.push(ctx.provide('inspector', createInspectorService(source))) + } catch (error) { + try { + disposeInspectorClient(source, disposers) + } catch (cleanupError) { + ctx.logger.error('experimental-inspector: Client initialization rollback failed', cleanupError) + } + throw error } + return () => { disposeInspectorClient(source, disposers) } }, 'experimental-inspector: Client source') } + +function disposeInspectorClient(source: ReturnType, disposers: readonly (() => unknown)[]): void { + const failures: unknown[] = [] + for (const dispose of [...disposers].reverse()) { + try { + dispose() + } catch (error) { + failures.push(error) + } + } + try { + source.close() + } catch (error) { + failures.push(error) + } + if (failures.length > 0) throw new AggregateError(failures, 'experimental-inspector: Client disposal failed') +} diff --git a/packages/experimental/inspector/src/host/bridge/dispatcher.ts b/packages/experimental/inspector/src/host/bridge/dispatcher.ts index 441dcb1512..16e4aba219 100644 --- a/packages/experimental/inspector/src/host/bridge/dispatcher.ts +++ b/packages/experimental/inspector/src/host/bridge/dispatcher.ts @@ -1,6 +1,12 @@ /** Dispatch of validated Worker frames accepted by the Host MessagePort. */ -import type { SourceAcceptedFrame, SourceRejectedFrame, SourceResnapshotFrame, WorkerToSourceFrame } from '../../shared/bridge/messages/observation.ts' +import type { + SourceAcceptedFrame, + SourceAppendAcknowledgedFrame, + SourceRejectedFrame, + SourceResnapshotFrame, + WorkerToSourceFrame, +} from '../../shared/bridge/messages/observation.ts' import { rejectConsoleBridgeCommand } from '../cdp/console.ts' import { rejectRuntimeBridgeCommand } from '../cdp/runtime.ts' import { rejectSourcesBridgeCommand } from '../cdp/sources.ts' @@ -8,6 +14,7 @@ import { rejectSourcesBridgeCommand } from '../cdp/sources.ts' /** Operations invoked for source-lifecycle frames addressed to the Host. */ export interface HostBridgeFrameHandlers { accepted(frame: SourceAcceptedFrame): void + acknowledged(frame: SourceAppendAcknowledgedFrame): void resnapshot(frame: SourceResnapshotFrame): void rejected(frame: SourceRejectedFrame): void } @@ -22,6 +29,9 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBr case 'source/accepted': handlers.accepted(frame) return + case 'source/append-acknowledged': + handlers.acknowledged(frame) + return case 'source/resnapshot': handlers.resnapshot(frame) return @@ -30,6 +40,9 @@ export function dispatchBridgeFrame(frame: WorkerToSourceFrame, handlers: HostBr return case 'client-runtime/request': return rejectRuntimeBridgeCommand(frame.command) + case 'client-runtime/cancel': + case 'client-runtime/response-acknowledged': + return case 'client-console/enable': case 'client-console/disable': return rejectConsoleBridgeCommand(frame.t) diff --git a/packages/experimental/inspector/src/host/bridge/publisher.ts b/packages/experimental/inspector/src/host/bridge/publisher.ts index 89564261da..6b272ba74a 100644 --- a/packages/experimental/inspector/src/host/bridge/publisher.ts +++ b/packages/experimental/inspector/src/host/bridge/publisher.ts @@ -10,6 +10,7 @@ import type { InspectorSourceDescriptor } from '../../shared/bridge/messages/obs export class HostBridgePublisher implements InspectorStatePublisher { private readonly records: InspectorSourceBuffer private flushScheduled = false + private inFlightNextSequence: number | undefined private closed = false constructor( @@ -34,23 +35,39 @@ export class HostBridgePublisher implements InspectorStatePublisher { /** Send the retained state as a complete source replacement. */ replace(): void { + this.inFlightNextSequence = undefined this.port.postMessage(this.records.replacement(this.source.sourceId, this.source.generation)) + this.scheduleFlush() } - /** Flush every currently queued observation batch. */ + /** Send one queued batch when no earlier MessagePort batch awaits acknowledgement. */ flush(): void { - let frame = this.records.takeBatch(this.source.sourceId, this.source.generation) - while (frame !== undefined) { - this.port.postMessage(frame) - frame = this.records.takeBatch(this.source.sourceId, this.source.generation) - } + if (this.closed || this.inFlightNextSequence !== undefined) return + const frame = this.records.takeBatch(this.source.sourceId, this.source.generation) + if (frame === undefined) return + this.port.postMessage(frame) + this.inFlightNextSequence = frame.firstSequence + frame.records.length } - /** Flush pending observations and reject later publication. */ + /** + * Release one in-flight batch and schedule the next bounded transfer. + * @param nextSequence - First sequence expected by the Worker after the accepted batch. + */ + acknowledge(nextSequence: number): void { + if (this.closed || this.inFlightNextSequence === undefined) return + if (nextSequence !== this.inFlightNextSequence) { + throw new Error('inspector: Host source acknowledgement does not match the in-flight batch') + } + this.inFlightNextSequence = undefined + this.scheduleFlush() + } + + /** Send at most one final batch, discard later queued observations, and reject publication. */ close(): void { if (this.closed) return this.flush() this.closed = true + this.records.discardPending() } private scheduleFlush(): void { diff --git a/packages/experimental/inspector/src/host/bridge/transport.ts b/packages/experimental/inspector/src/host/bridge/transport.ts index fb526a020a..2f5e0a9f68 100644 --- a/packages/experimental/inspector/src/host/bridge/transport.ts +++ b/packages/experimental/inspector/src/host/bridge/transport.ts @@ -81,6 +81,7 @@ export class HostInspectorSource extends InspectorSourceConnection { && (frame.sourceId !== this.source.sourceId || frame.generation !== this.source.generation)) return dispatchBridgeFrame(frame, { accepted: () => { this.queries.connectPort(this.source) }, + acknowledged: (acknowledged) => { this.publisher.acknowledge(acknowledged.nextSequence) }, resnapshot: () => { this.publisher.replace() }, rejected: (rejected) => { this.queries.disconnect(`Inspector Host source rejected: ${rejected.message}`) }, }) diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts index aa5225a8c5..8ff9af0477 100644 --- a/packages/experimental/inspector/src/host/inspection/network.ts +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -120,6 +120,14 @@ export function installFetchObserver( controller.signal, (data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) }, ).then((outcome) => { + if (request.signal.aborted && outcome.captureError !== undefined) { + publisher.publish('fetch/error', { + requestId, + message: outcome.captureError, + canceled: true, + }) + return + } publisher.publish('fetch/end', { requestId, capturedBytes: outcome.capturedBytes, diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts index 039eb1b8ca..0e54f3425e 100644 --- a/packages/experimental/inspector/src/host/plugin.ts +++ b/packages/experimental/inspector/src/host/plugin.ts @@ -49,7 +49,7 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise { table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client }) })) - console.log(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`) + ctx.logger.info(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`) } catch (error) { await disposeInspector(handle, disposers).catch((cleanupError: unknown) => { ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError) diff --git a/packages/experimental/inspector/src/shared/bridge/buffer.ts b/packages/experimental/inspector/src/shared/bridge/buffer.ts index c058fbe18a..032e01afc2 100644 --- a/packages/experimental/inspector/src/shared/bridge/buffer.ts +++ b/packages/experimental/inspector/src/shared/bridge/buffer.ts @@ -42,6 +42,7 @@ export class InspectorSourceBuffer { /** * Validate and enqueue one observation, dropping the oldest prefix as needed. + * A record larger than one transport frame is dropped after consuming its sequence number. * @param topic - Declared domain topic. * @param payload - Lossless JSON payload. * @param monotonicMs - Finite source-clock timestamp. @@ -97,11 +98,9 @@ export class InspectorSourceBuffer { if (this.queue.length === 0) return undefined const batch: QueuedRecord[] = [] let batchBytes = SOURCE_FRAME_OVERHEAD_BYTES - const first = this.queue[0] - if (first === undefined) throw new Error('inspector: non-empty source queue has no first record') + const first = this.queue[0] as QueuedRecord while (batch.length < this.options.maxRecordsPerFrame && this.queue.length > 0) { - const candidate = this.queue[0] - if (candidate === undefined) break + const candidate = this.queue[0] as QueuedRecord if (candidate.sequence !== first.sequence + batch.length) break if (batch.length > 0 && batchBytes + candidate.bytes > this.options.maxFrameBytes) break this.queue.shift() @@ -123,6 +122,12 @@ export class InspectorSourceBuffer { return frame } + /** Discard observations that have not entered a transport frame. */ + discardPending(): void { + this.queue.length = 0 + this.queuedBytes = 0 + } + private record(topic: string, payload: InspectorJsonValue, monotonicMs: number): InspectorRecordInput { if (topic.length === 0 || topic.length > 128) { throw new Error('inspector: topic must contain 1 to 128 characters') @@ -144,8 +149,7 @@ export class InspectorSourceBuffer { this.queue.push({ sequence, bytes, record }) this.queuedBytes += bytes while (this.queue.length > this.options.maxQueuedRecords || this.queuedBytes > this.options.maxQueuedBytes) { - const dropped = this.queue.shift() - if (dropped === undefined) break + const dropped = this.queue.shift() as QueuedRecord this.queuedBytes -= dropped.bytes } } diff --git a/packages/experimental/inspector/src/shared/bridge/messages/observation.ts b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts index 1127eb0d85..ac7f1403e8 100644 --- a/packages/experimental/inspector/src/shared/bridge/messages/observation.ts +++ b/packages/experimental/inspector/src/shared/bridge/messages/observation.ts @@ -9,7 +9,9 @@ import { parseClientConsoleControlFrame, parseClientConsoleEventFrame, parseClientRuntimeCapability, + parseClientRuntimeCancelFrame, parseClientRuntimeRequestFrame, + parseClientRuntimeResponseAcknowledgedFrame, parseClientRuntimeResponseFrame, parseClientRuntimeSessionClosedFrame, type ClientConsoleCapability, @@ -17,7 +19,9 @@ import { type ClientConsoleEnableFrame, type ClientConsoleEventFrame, type ClientRuntimeCapability, + type ClientRuntimeCancelFrame, type ClientRuntimeRequestFrame, + type ClientRuntimeResponseAcknowledgedFrame, type ClientRuntimeResponseFrame, type ClientRuntimeSessionClosedFrame, } from './runtime/index.ts' @@ -114,6 +118,15 @@ export interface SourceAcceptedFrame { readonly generation: InspectorSourceGeneration } +/** Worker acknowledgement that releases one Host MessagePort batch credit. */ +export interface SourceAppendAcknowledgedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'source/append-acknowledged' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly nextSequence: number +} + /** Worker request for a complete source-state replacement. */ export interface SourceResnapshotFrame { readonly v: typeof INSPECTOR_PROTOCOL_VERSION @@ -135,11 +148,14 @@ export interface SourceRejectedFrame { /** Every Worker-to-source control frame. */ export type WorkerToSourceFrame = | SourceAcceptedFrame + | SourceAppendAcknowledgedFrame | SourceResnapshotFrame | SourceRejectedFrame | ClientConsoleEnableFrame | ClientConsoleDisableFrame + | ClientRuntimeCancelFrame | ClientRuntimeRequestFrame + | ClientRuntimeResponseAcknowledgedFrame | ClientRuntimeSessionClosedFrame | ClientSourceRequestFrame | ClientSourceSessionClosedFrame @@ -165,6 +181,10 @@ export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame { return { v: INSPECTOR_PROTOCOL_VERSION, t: 'source/rejected', code: value.code, message: value.message } } if (value.t === 'client-runtime/request') return parseClientRuntimeRequestFrame(value) + if (value.t === 'client-runtime/cancel') return parseClientRuntimeCancelFrame(value) + if (value.t === 'client-runtime/response-acknowledged') { + return parseClientRuntimeResponseAcknowledgedFrame(value) + } if (value.t === 'client-runtime/session-closed') return parseClientRuntimeSessionClosedFrame(value) if (value.t === 'client-sources/request') return parseClientSourceRequestFrame(value) if (value.t === 'client-sources/session-closed') return parseClientSourceSessionClosedFrame(value) @@ -180,6 +200,14 @@ export function parseWorkerSourceFrame(value: unknown): WorkerToSourceFrame { exactKeys(value, ['v', 't', 'sourceId', 'generation'], 'source/accepted frame') return { ...common, t: 'source/accepted' } } + if (value.t === 'source/append-acknowledged') { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'nextSequence'], 'source append acknowledgement') + return { + ...common, + t: 'source/append-acknowledged', + nextSequence: natural(value.nextSequence, 'nextSequence'), + } + } if (value.t === 'source/resnapshot' && typeof value.reason === 'string') { exactKeys(value, ['v', 't', 'sourceId', 'generation', 'expectedSequence', 'reason'], 'source/resnapshot frame') diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts index a6ed8f620b..aa58dd1290 100644 --- a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts @@ -30,6 +30,26 @@ export interface ClientRuntimeRequestFrame { readonly command: ClientRuntimeCommand } +/** Worker cancellation of one outstanding Client Runtime request. */ +export interface ClientRuntimeCancelFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/cancel' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly requestId: ClientRuntimeRequestId +} + +/** Worker acknowledgement that commits one successful Client Runtime response. */ +export interface ClientRuntimeResponseAcknowledgedFrame { + readonly v: typeof INSPECTOR_PROTOCOL_VERSION + readonly t: 'client-runtime/response-acknowledged' + readonly sourceId: InspectorSourceId + readonly generation: InspectorSourceGeneration + readonly sessionId: ClientRuntimeSessionId + readonly requestId: ClientRuntimeRequestId +} + /** Client response to one typed Runtime request. */ export interface ClientRuntimeResponseFrame { readonly v: typeof INSPECTOR_PROTOCOL_VERSION @@ -86,6 +106,48 @@ export function parseClientRuntimeRequestFrame(value: Record): } } +/** + * Parse and rebuild one Worker-to-Client Runtime cancellation. + * @param value - Untrusted cancellation frame. + * @returns The validated cancellation frame. + */ +export function parseClientRuntimeCancelFrame(value: Record): ClientRuntimeCancelFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId'], 'Client Runtime cancellation') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/cancel') { + throw new Error('inspector protocol: invalid Client Runtime cancellation envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/cancel', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), + } +} + +/** + * Parse and rebuild one Worker acknowledgement for a Client Runtime response. + * @param value - Untrusted acknowledgement frame. + * @returns The validated acknowledgement frame. + */ +export function parseClientRuntimeResponseAcknowledgedFrame( + value: Record, +): ClientRuntimeResponseAcknowledgedFrame { + exactKeys(value, ['v', 't', 'sourceId', 'generation', 'sessionId', 'requestId'], 'Client Runtime response acknowledgement') + if (value.v !== INSPECTOR_PROTOCOL_VERSION || value.t !== 'client-runtime/response-acknowledged') { + throw new Error('inspector protocol: invalid Client Runtime response acknowledgement envelope') + } + return { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/response-acknowledged', + sourceId: wireId<'InspectorSourceId'>(value.sourceId, 'sourceId'), + generation: wireId<'InspectorSourceGeneration'>(value.generation, 'generation'), + sessionId: wireId<'ClientRuntimeSessionId'>(value.sessionId, 'sessionId'), + requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), + } +} + /** * Parse and rebuild one Client-to-Worker Runtime response. * @param value - Untrusted response frame. diff --git a/packages/experimental/inspector/src/shared/cdp/operations.ts b/packages/experimental/inspector/src/shared/cdp/operations.ts index 56692a22dc..ebfa6d7e5a 100644 --- a/packages/experimental/inspector/src/shared/cdp/operations.ts +++ b/packages/experimental/inspector/src/shared/cdp/operations.ts @@ -16,9 +16,15 @@ export type RuntimeCallArgument = | { readonly kind: 'object'; readonly handle: Handle } | { readonly kind: 'undefined' } +/** Backend-local selector for a native execution context within one realm. */ +export type RuntimeExecutionContext = + | { readonly kind: 'numeric'; readonly id: number } + | { readonly kind: 'unique'; readonly id: string } + /** Engine-independent evaluation options supported by Runtime backends. */ export interface RuntimeEvaluateRequest { readonly expression: string + readonly context?: RuntimeExecutionContext readonly objectGroup?: string readonly includeCommandLineAPI?: boolean readonly silent?: boolean @@ -46,6 +52,7 @@ export interface RuntimeGetPropertiesRequest { /** Function invocation request within one inspected realm. */ export interface RuntimeCallFunctionRequest { readonly functionDeclaration: string + readonly context?: RuntimeExecutionContext readonly receiver?: Handle readonly arguments?: readonly RuntimeCallArgument[] readonly objectGroup?: string diff --git a/packages/experimental/inspector/src/shared/cdp/realm.ts b/packages/experimental/inspector/src/shared/cdp/realm.ts index 38b61afb11..798b76e68d 100644 --- a/packages/experimental/inspector/src/shared/cdp/realm.ts +++ b/packages/experimental/inspector/src/shared/cdp/realm.ts @@ -12,6 +12,7 @@ import type { RuntimeCallFrameEvaluationRequest, RuntimeEvaluateRequest, RuntimeGetPropertiesRequest, + RuntimeExecutionContext, RuntimeProperties, RuntimeScript, } from './index.ts' @@ -63,8 +64,12 @@ export interface RuntimeBackend { awaitPromise( request: RuntimeAwaitPromiseRequest, ): Promise> - /** @returns Names visible in the realm's global lexical scope. */ - globalLexicalScopeNames(): Promise + /** + * Read names visible in one backend execution context's global lexical scope. + * @param context - Native sub-context selector, or the realm default when omitted. + * @returns Names visible in the selected global lexical scope. + */ + globalLexicalScopeNames(context?: RuntimeExecutionContext): Promise /** * Release one backend object reference. * @param handle - Handle owned by this realm session. diff --git a/packages/experimental/inspector/src/shared/cordis/collector.ts b/packages/experimental/inspector/src/shared/cordis/collector.ts index 622e4d6158..511a7fa14f 100644 --- a/packages/experimental/inspector/src/shared/cordis/collector.ts +++ b/packages/experimental/inspector/src/shared/cordis/collector.ts @@ -48,10 +48,11 @@ export class CordisTreeCollector { * @returns A detached JSON snapshot whose retained objects replace the prior generation atomically. */ snapshot(): CordisTreeSnapshot { - const tree = collectContexts(this.root) + const collected = collectContexts(this.root) + const tree = collected.root const objects = this.objects.begin() let nodeCount = 0 - let truncated = false + let truncated = collected.truncated const contextNode = (info: ContextInfo): MutableContextNode | undefined => { if (nodeCount >= this.limits.maxNodes) { @@ -82,8 +83,7 @@ export class CordisTreeCollector { return undefined } nodeCount++ - const context = contextNode(owned) - if (context === undefined) throw new Error('inspector: reserved Fiber Context was not collected') + const context = contextNode(owned) as MutableContextNode return { kind: 'fiber', objectHandle: objects.retain(fiber).handle, @@ -120,10 +120,14 @@ export class CordisTreeCollector { } } -function collectContexts(root: Context): ContextInfo { +function collectContexts(root: Context): { readonly root: ContextInfo; readonly truncated: boolean } { const contexts = new Map() + let truncated = false const ensure = (candidate: unknown, depth = 0): ContextInfo | undefined => { - if (depth > 100) return undefined + if (depth > 100) { + truncated = true + return undefined + } const value = unwrapContext(candidate) if (!Context.is(value)) return undefined const existing = contexts.get(value) @@ -142,8 +146,7 @@ function collectContexts(root: Context): ContextInfo { return info } - const rootInfo = ensure(root) - if (rootInfo === undefined) throw new Error('inspector: Cordis root context is not reachable') + const rootInfo = ensure(root) as ContextInfo for (const runtime of root.registry.values()) { for (const fiber of runtime.fibers) { if (fiber.uid === null) continue @@ -158,7 +161,7 @@ function collectContexts(root: Context): ContextInfo { for (const info of contexts.values()) { info.children.sort((left, right) => order(left) - order(right)) } - return rootInfo + return { root: rootInfo, truncated } } function describeContext(value: Context): ContextInfo { diff --git a/packages/experimental/inspector/src/shared/cordis/model.ts b/packages/experimental/inspector/src/shared/cordis/model.ts index 86750e508d..b2ed6443b0 100644 --- a/packages/experimental/inspector/src/shared/cordis/model.ts +++ b/packages/experimental/inspector/src/shared/cordis/model.ts @@ -146,8 +146,7 @@ function parseNode(value: unknown, state: ParseState, depth: number): CordisRunt if (record.kind === 'context') { return { kind: 'context', children: record.children.map(child => parseNode(child, state, depth + 1)) } } - if (record.kind !== 'fiber' - || !Number.isSafeInteger(record.uid) + if (!Number.isSafeInteger(record.uid) || (record.uid as number) < 1 || record.children.length !== 1) { throw new Error('inspector protocol: invalid Cordis runtime Fiber') diff --git a/packages/experimental/inspector/src/shared/cordis/projector.ts b/packages/experimental/inspector/src/shared/cordis/projector.ts index d0d02efe77..7ef4f7cdbb 100644 --- a/packages/experimental/inspector/src/shared/cordis/projector.ts +++ b/packages/experimental/inspector/src/shared/cordis/projector.ts @@ -69,20 +69,10 @@ function projectContext(node: Extract): Cor } function projectNode(node: CordisTreeNode): CordisRuntimeNode { - switch (node.kind) { - case 'context': - return projectContext(node) - case 'fiber': - return { - kind: 'fiber', - uid: node.uid, - children: [projectContext(node.children[0])], - } - default: - return assertNever(node) + if (node.kind === 'context') return projectContext(node) + return { + kind: 'fiber', + uid: node.uid, + children: [projectContext(node.children[0])], } } - -function assertNever(value: never): never { - throw new Error(`Unexpected Cordis tree node: ${JSON.stringify(value)}`) -} diff --git a/packages/experimental/inspector/src/worker/bridge/hub.ts b/packages/experimental/inspector/src/worker/bridge/hub.ts index dfdffc2eee..d994b1bcb0 100644 --- a/packages/experimental/inspector/src/worker/bridge/hub.ts +++ b/packages/experimental/inspector/src/worker/bridge/hub.ts @@ -251,6 +251,13 @@ export class InspectorSourceRegistry { state.expectedSequence = frame.firstSequence + frame.records.length for (const consumer of this.consumers) consumer.append(state.source, records) this.count(state, frame.records) + connection.send({ + v: INSPECTOR_PROTOCOL_VERSION, + t: 'source/append-acknowledged', + sourceId: state.source.sourceId, + generation: state.source.generation, + nextSequence: state.expectedSequence, + }) this.notifyStatus() } diff --git a/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts index e4e66c5031..6de58b245f 100644 --- a/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts +++ b/packages/experimental/inspector/src/worker/bridge/runtime-rpc.ts @@ -154,8 +154,10 @@ export class ClientRuntimeRouter { const requestId = inspectorId<'ClientRuntimeRequestId'>(randomUUID(), 'requestId') return new Promise((resolve, reject) => { const timer = setTimeout(() => { - this.pending.delete(requestId) - reject(new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`)) + const pending = this.pending.get(requestId) + if (pending === undefined) return + this.cancelClientResponse(target.source, sessionId, requestId) + this.rejectPending(requestId, new Error(`Client Runtime ${command.op} timed out after ${String(this.timeoutMs)}ms`)) }, this.timeoutMs) timer.unref() this.pending.set(requestId, { target, sessionId, op: command.op, resolve, reject, timer }) @@ -278,28 +280,78 @@ export class ClientRuntimeRouter { private settle(source: InspectorSourceDescriptor, frame: ClientRuntimeResponseFrame): void { const pending = this.pending.get(frame.requestId) - if (pending === undefined) return + if (pending === undefined) { + this.cancelClientResponse(source, frame.sessionId, frame.requestId) + return + } if (pending.target.source.sourceId !== source.sourceId || pending.target.source.generation !== source.generation || pending.sessionId !== frame.sessionId) { + this.cancelClientResponse(source, frame.sessionId, frame.requestId) + this.cancelClientResponse(pending.target.source, pending.sessionId, frame.requestId) this.rejectPending(frame.requestId, new Error('Client Runtime response correlation mismatch')) return } if (!frame.outcome.ok) { + this.acknowledgeClientResponse(source, frame.sessionId, frame.requestId) this.rejectPending(frame.requestId, new ClientRuntimeRemoteError(frame.outcome.error.code, frame.outcome.error.message)) return } if (frame.outcome.result.op !== pending.op) { + this.cancelClientResponse(source, frame.sessionId, frame.requestId) this.rejectPending(frame.requestId, new Error( `Client Runtime response op ${frame.outcome.result.op} does not match ${pending.op}`, )) return } + if (!this.acknowledgeClientResponse(source, frame.sessionId, frame.requestId)) { + this.rejectPending(frame.requestId, new Error('Client execution context disconnected before acknowledgement')) + return + } clearTimeout(pending.timer) this.pending.delete(frame.requestId) pending.resolve(frame.outcome.result) } + private acknowledgeClientResponse( + source: InspectorSourceDescriptor, + sessionId: ClientRuntimeSessionId, + requestId: ClientRuntimeRequestId, + ): boolean { + try { + return this.sources.send(source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/response-acknowledged', + sourceId: source.sourceId, + generation: source.generation, + sessionId, + requestId, + }) + } catch { + // A failed acknowledgement rejects the Worker request; source teardown releases Client handles. + return false + } + } + + private cancelClientResponse( + source: InspectorSourceDescriptor, + sessionId: ClientRuntimeSessionId, + requestId: ClientRuntimeRequestId, + ): void { + try { + this.sources.send(source, { + v: INSPECTOR_PROTOCOL_VERSION, + t: 'client-runtime/cancel', + sourceId: source.sourceId, + generation: source.generation, + sessionId, + requestId, + }) + } catch { + // Cancellation settlement does not depend on delivery to a source that may be closing. + } + } + private rejectPending(requestId: ClientRuntimeRequestId, error: Error): void { const pending = this.pending.get(requestId) if (pending === undefined) return diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts index b38c40b334..2e7636979b 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/cdp-params.ts @@ -194,7 +194,7 @@ export function parseReleaseObjectGroup(params: Readonly * @returns The validated context selector. */ export function parseGlobalLexicalScopeNames(params: Readonly>): CdpExecutionContextSelector { - exactKeys(params, ['executionContextId', 'uniqueContextId'], 'Runtime.globalLexicalScopeNames params') + exactKeys(params, ['executionContextId'], 'Runtime.globalLexicalScopeNames params') return parseContextSelector(params, 'executionContextId') } diff --git a/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts index 9b259ab7fb..aa34b37254 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/runtime/session.ts @@ -3,6 +3,7 @@ import type { InspectorSourceDescriptor } from '../../../../shared/bridge/messages/observation.ts' import type { InspectorRealmId, RuntimeBackendObjectHandle } from '../../../../shared/cdp/ids.ts' import type { RuntimeCallArgument, RuntimeCompletion, RuntimeRemoteObject } from '../../../../shared/cdp/index.ts' +import type { RuntimeExecutionContext } from '../../../../shared/cdp/operations.ts' import type { RuntimeBackend } from '../../../../shared/cdp/realm.ts' import { cdpError, respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../protocol.ts' import type { InspectorRealmSession } from '../../../inspection/realm.ts' @@ -228,7 +229,10 @@ export class RuntimeDomainSession { private async evaluate(params: Readonly>): Promise { const parsed = parseEvaluate(params) const realm = this.realmFromSelector(parsed, 'contextId') - const completion = await runtimeBackend(realm).evaluate(parsed.request) + const completion = await runtimeBackend(realm).evaluate({ + ...parsed.request, + ...this.backendContext(realm, parsed, 'contextId'), + }) return this.objects.completion(realm, completion, parsed.request.objectGroup) } @@ -260,6 +264,7 @@ export class RuntimeDomainSession { const group = parsed.request.objectGroup ?? receiver?.group const completion = await runtimeBackend(realm).callFunction({ ...parsed.request, + ...this.backendContext(realm, parsed, 'executionContextId'), ...(receiver === undefined ? {} : { receiver: receiver.handle }), arguments: parsed.arguments.map(argument => this.routeArgument(realm, argument)), }) @@ -309,7 +314,8 @@ export class RuntimeDomainSession { private async globalLexicalScopeNames(params: Readonly>): Promise { const parsed = parseGlobalLexicalScopeNames(params) const realm = this.realmFromSelector(parsed, 'executionContextId') - return { names: await runtimeBackend(realm).globalLexicalScopeNames() } + const context = this.backendContext(realm, parsed, 'executionContextId').context + return { names: await runtimeBackend(realm).globalLexicalScopeNames(context) } } private async discardConsoleEntries(): Promise { @@ -349,6 +355,19 @@ export class RuntimeDomainSession { return undefined } + private backendContext( + realm: InspectorRealmSession, + params: CdpExecutionContextSelector, + numericKey: 'contextId' | 'executionContextId', + ): { readonly context?: RuntimeExecutionContext } { + if (realm.context.kind !== 'native') return {} + const numeric = params[numericKey] + if (typeof numeric === 'number') return { context: { kind: 'numeric', id: numeric } } + return params.uniqueContextId === undefined + ? {} + : { context: { kind: 'unique', id: params.uniqueContextId } } + } + private routeArgument( realm: InspectorRealmSession, argument: CdpCallArgument, diff --git a/packages/experimental/inspector/src/worker/inspection/network-store.ts b/packages/experimental/inspector/src/worker/inspection/network-store.ts index cf1d80b90c..581c0d2bad 100644 --- a/packages/experimental/inspector/src/worker/inspection/network-store.ts +++ b/packages/experimental/inspector/src/worker/inspection/network-store.ts @@ -145,8 +145,7 @@ export class NetworkStore implements InspectorRecordConsumer { for (const event of this.journal) { replay.push(event) if (event.type !== 'response-received' || event.mimeType !== 'text/event-stream') continue - const request = this.requests.get(event.requestKey) - if (request === undefined) continue + const request = this.requests.get(event.requestKey) as CapturedRequest const messages = new InspectorEventSourceParser().push(Buffer.concat(request.responseBody)) let eventId = 0 for (const message of messages) { @@ -301,18 +300,23 @@ export class NetworkStore implements InspectorRecordConsumer { }) return } - case 'fetch/error': + case 'fetch/error': { + if (request.completed) return + const errorText = stringField(payload, 'message') + if (request.responseSeen) { + request.responseBodyTruncated = true + request.responseCaptureError = errorText + } this.complete(request, { type: 'request-failed', requestKey: key, requestId: request.requestId, timestampMs, - errorText: stringField(payload, 'message'), + errorText, canceled: booleanField(payload, 'canceled'), }) return - default: - return + } } } @@ -359,10 +363,8 @@ export class NetworkStore implements InspectorRecordConsumer { private enforceRetention(): void { while (this.requests.size > this.options.maxRetainedRequests || this.journalBytes > this.options.maxJournalBytes) { - const key = this.completed.shift() ?? this.oldestActiveRequestKey() - if (key === undefined) return - const request = this.requests.get(key) - if (request === undefined) continue + const key = (this.completed.shift() ?? this.requests.keys().next().value) as string + const request = this.requests.get(key) as CapturedRequest if (!request.completed) { request.completed = true this.publish({ @@ -378,21 +380,12 @@ export class NetworkStore implements InspectorRecordConsumer { } } - private oldestActiveRequestKey(): string | undefined { - for (const request of this.requests.values()) { - if (!request.completed) return request.key - } - return undefined - } - private evictCompletedFor(bytes: number, protectedKey: string): void { while (this.journalBytes + bytes > this.options.maxJournalBytes) { const index = this.completed.findIndex(key => key !== protectedKey) if (index === -1) return - const [key] = this.completed.splice(index, 1) - if (key === undefined) return - const request = this.requests.get(key) - if (request !== undefined) this.evict(request) + const key = this.completed.splice(index, 1)[0] as string + this.evict(this.requests.get(key) as CapturedRequest) } } diff --git a/packages/experimental/inspector/src/worker/realms/client/runtime.ts b/packages/experimental/inspector/src/worker/realms/client/runtime.ts index fd298bcb56..c1b68c235e 100644 --- a/packages/experimental/inspector/src/worker/realms/client/runtime.ts +++ b/packages/experimental/inspector/src/worker/realms/client/runtime.ts @@ -41,7 +41,12 @@ export class ClientRuntimeBackend implements RuntimeBackend { async evaluate(request: Parameters[0]): ReturnType { assertClientEvaluationOptions(request) - const { throwOnSideEffect: _throwOnSideEffect, serializationOptions: _serializationOptions, ...supported } = request + const { + context: _context, + throwOnSideEffect: _throwOnSideEffect, + serializationOptions: _serializationOptions, + ...supported + } = request return clientCompletion( expectResult(await this.request({ op: 'evaluate', ...supported }), 'evaluate'), scriptKey => this.scriptIds.toRuntime(scriptKey), @@ -74,6 +79,7 @@ export class ClientRuntimeBackend implements RuntimeBackend { assertClientCallOptions(request) const { receiver, + context: _context, arguments: args, throwOnSideEffect: _throwOnSideEffect, serializationOptions: _serializationOptions, @@ -102,7 +108,8 @@ export class ClientRuntimeBackend implements RuntimeBackend { ) } - async globalLexicalScopeNames(): Promise { + async globalLexicalScopeNames(context?: Parameters[0]): Promise { + if (context !== undefined) throw new Error('Client Runtime does not support native execution contexts') return expectResult(await this.request({ op: 'global-lexical-scope-names' }), 'global-lexical-scope-names').names } @@ -140,6 +147,7 @@ function expectResult( } function assertClientEvaluationOptions(request: Parameters[0]): void { + if (request.context !== undefined) throw new Error('Client Runtime does not support native execution contexts') if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect') if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions') if (request.disableBreaks === true) throw new Error('Client Runtime does not support disableBreaks') @@ -152,6 +160,7 @@ function assertClientEvaluationOptions(request: Parameters[0]): void { + if (request.context !== undefined) throw new Error('Client Runtime does not support native execution contexts') if (request.throwOnSideEffect === true) throw new Error('Client Runtime does not support throwOnSideEffect') if (request.serializationOptions !== undefined) throw new Error('Client Runtime does not support serializationOptions') if (request.userGesture === true) throw new Error('Client Runtime does not support userGesture') diff --git a/packages/experimental/inspector/src/worker/realms/host/runtime.ts b/packages/experimental/inspector/src/worker/realms/host/runtime.ts index bc1bd4d50f..07efbf8385 100644 --- a/packages/experimental/inspector/src/worker/realms/host/runtime.ts +++ b/packages/experimental/inspector/src/worker/realms/host/runtime.ts @@ -15,6 +15,7 @@ import type { RuntimePropertyDescriptor, RuntimeRemoteObject, RuntimeRemoteObjectDescriptor, + RuntimeExecutionContext, RuntimeStackTrace, } from '../../../shared/cdp/index.ts' import type { HostInspectorNotification, HostInspectorSession } from './bridge.ts' @@ -43,6 +44,7 @@ export class HostRuntimeBackend implements RuntimeBackend { async evaluate(request: Parameters[0]): ReturnType { return this.completion(await this.target.request('Runtime.evaluate', { expression: request.expression, + ...nativeContext(request.context, 'contextId'), ...optionalNativeField('objectGroup', request.objectGroup), ...optionalNativeField('includeCommandLineAPI', request.includeCommandLineAPI), ...optionalNativeField('silent', request.silent), @@ -72,13 +74,15 @@ export class HostRuntimeBackend implements RuntimeBackend { async callFunction(request: Parameters[0]): ReturnType { const receiver = request.receiver - const contextId = receiver === undefined ? this.defaultContextId : undefined - if (receiver === undefined && contextId === undefined) { + const context = receiver === undefined + ? nativeContext(request.context ?? defaultContext(this.defaultContextId), 'executionContextId') + : undefined + if (receiver === undefined && context === undefined) { throw new Error('Host Runtime default execution context is unavailable') } return this.completion(await this.target.request('Runtime.callFunctionOn', { functionDeclaration: request.functionDeclaration, - ...(receiver === undefined ? { executionContextId: contextId } : { objectId: receiver }), + ...(receiver === undefined ? context : { objectId: receiver }), ...(request.arguments === undefined ? {} : { arguments: request.arguments.map(toNativeArgument) }), ...optionalNativeField('objectGroup', request.objectGroup), ...optionalNativeField('silent', request.silent), @@ -99,9 +103,9 @@ export class HostRuntimeBackend implements RuntimeBackend { })) } - async globalLexicalScopeNames(): Promise { + async globalLexicalScopeNames(context?: RuntimeExecutionContext): Promise { const response = await this.target.request('Runtime.globalLexicalScopeNames', { - ...optionalNativeField('executionContextId', this.defaultContextId), + ...nativeContext(context ?? defaultContext(this.defaultContextId), 'executionContextId'), }) if (!Array.isArray(response.names) || !response.names.every(name => typeof name === 'string')) { throw new Error('Host Runtime returned invalid lexical scope names') @@ -303,6 +307,18 @@ export class HostRuntimeBackend implements RuntimeBackend { } } +function defaultContext(contextId: number | undefined): RuntimeExecutionContext | undefined { + return contextId === undefined ? undefined : { kind: 'numeric', id: contextId } +} + +function nativeContext( + context: RuntimeExecutionContext | undefined, + numericKey: 'contextId' | 'executionContextId', +): Readonly> | undefined { + if (context === undefined) return undefined + return context.kind === 'numeric' ? { [numericKey]: context.id } : { uniqueContextId: context.id } +} + function toNativeArgument(value: RuntimeCallArgument): Readonly> { switch (value.kind) { case 'value': return { value: value.value } diff --git a/packages/experimental/inspector/tests/client-runtime.client.spec.ts b/packages/experimental/inspector/tests/client-runtime.client.spec.ts index c07e0b85d2..8e2a787b7b 100644 --- a/packages/experimental/inspector/tests/client-runtime.client.spec.ts +++ b/packages/experimental/inspector/tests/client-runtime.client.spec.ts @@ -162,6 +162,55 @@ describe('Client Runtime executor', () => { .toMatchObject({ descriptor: { value: true } }) }) + it('rolls back a canceled function call instead of returning its cancellation as a JavaScript exception', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 1, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const controller = new AbortController() + const pending = runtime.execute(frame({ + op: 'call-function', + functionDeclaration: 'function () { return new Promise(() => {}) }', + awaitPromise: true, + }), controller.signal) + controller.abort() + + await expect(pending).resolves.toMatchObject({ outcome: { ok: false, error: { code: 'timeout' } } }) + await expect(runtime.execute(frame({ + op: 'evaluate', + expression: '({ retainedAfterCancellation: true })', + }))).resolves.toMatchObject({ outcome: { ok: true } }) + }) + + it('keeps response handles provisional until the Worker accepts or cancels them', async () => { + const runtime = new ClientRuntimeExecutor({ + maxObjectsPerSession: 2, + maxPropertiesPerResult: 100, + maxResponseBytes: 32_768, + }) + const canceledFrame = frame({ op: 'evaluate', expression: '({ canceled: true })' }) + const canceled = success(await runtime.execute(canceledFrame, undefined, true), 'evaluate') + const canceledHandle = canceled.completion.result.object?.handle + if (canceledHandle === undefined) throw new Error('deferred response did not retain an object') + runtime.cancel(canceledFrame.sessionId, canceledFrame.requestId) + expect((await runtime.execute(frame({ op: 'get-properties', handle: canceledHandle }))).outcome) + .toMatchObject({ ok: false, error: { code: 'object-not-found' } }) + + const acceptedFrame = frame({ op: 'evaluate', expression: '({ accepted: true })' }) + const accepted = success(await runtime.execute(acceptedFrame, undefined, true), 'evaluate') + const acceptedHandle = accepted.completion.result.object?.handle + if (acceptedHandle === undefined) throw new Error('deferred response did not retain an object') + runtime.acknowledge(acceptedFrame.sessionId, acceptedFrame.requestId) + const properties = success(await runtime.execute(frame({ + op: 'get-properties', + handle: acceptedHandle, + ownProperties: true, + })), 'get-properties') + expect(properties.properties.find(property => property.name === 'accepted')?.value) + .toMatchObject({ descriptor: { value: true } }) + }) + it('rejects oversized by-value results before they enter the source transport', async () => { const runtime = new ClientRuntimeExecutor({ maxObjectsPerSession: 100, diff --git a/packages/experimental/inspector/tests/cordis-model.host.spec.ts b/packages/experimental/inspector/tests/cordis-model.host.spec.ts new file mode 100644 index 0000000000..a6a30b8b55 --- /dev/null +++ b/packages/experimental/inspector/tests/cordis-model.host.spec.ts @@ -0,0 +1,225 @@ +/** Validation and projection of the shared Cordis tree representations. */ + +import { describe, expect, it } from 'vitest' +import { parseCordisRuntimeTree } from '../src/shared/cordis/model.ts' +import { + identifyRealmObject, + RealmObjectRegistry, + realmObjectExpression, +} from '../src/shared/cordis/object-registry.ts' +import { parseInspectorObjectReference } from '../src/shared/cordis/object-reference.ts' +import { projectCordisRuntimeTree } from '../src/shared/cordis/projector.ts' +import { parseCordisTreeSnapshot, type CordisTreeSnapshot } from '../src/shared/cordis/snapshot.ts' + +describe('Cordis runtime tree model', () => { + it('parses connected and disconnected realms and rejects duplicate source identities', () => { + const tree = { + schemaVersion: 0, + host: realm('host-1', 'host', { state: 'connected' }), + clients: [realm('client-1', 'client', { state: 'disconnected', reason: 'offline' })], + } + expect(parseCordisRuntimeTree(tree)).toEqual(tree) + expect(parseCordisRuntimeTree({ schemaVersion: 0, host: null, clients: [] }).host).toBeNull() + expect(() => parseCordisRuntimeTree({ + ...tree, + clients: [realm('host-1', 'client', { state: 'connected' })], + })).toThrow('repeats a sourceId') + }) + + it.each([ + [{ schemaVersion: 1, host: null, clients: [] }, 'invalid Cordis runtime tree'], + [{ schemaVersion: 0, host: null, clients: {} }, 'invalid Cordis runtime tree'], + [{ schemaVersion: 0, host: realm('host-1', 'client', { state: 'connected' }), clients: [] }, 'invalid host Cordis runtime source'], + [{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { source: { sourceId: 'host-1', kind: 'host', label: '' } }), clients: [] }, 'invalid host Cordis runtime source'], + [{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { source: { sourceId: 'host-1', kind: 'host', label: 'x'.repeat(257) } }), clients: [] }, 'invalid host Cordis runtime source'], + [{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { revision: 0 }), clients: [] }, 'invalid Cordis runtime realm header'], + [{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'connected' }, { truncated: 'no' }), clients: [] }, 'invalid Cordis runtime realm header'], + [{ schemaVersion: 0, host: realm('host-1', 'host', null), clients: [] }, 'connection must be an object'], + [{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'disconnected', reason: 1 }), clients: [] }, 'invalid Cordis runtime connection'], + [{ schemaVersion: 0, host: realm('host-1', 'host', { state: 'unknown' }), clients: [] }, 'invalid Cordis runtime connection'], + ])('rejects malformed runtime tree headers %#', (value, message) => { + expect(() => parseCordisRuntimeTree(value)).toThrow(message) + }) + + it('rejects malformed runtime nodes, duplicate Fiber ids, and excessive depth', () => { + const withRoot = (root: unknown): unknown => ({ + schemaVersion: 0, + host: realm('host-1', 'host', { state: 'connected' }, { root }), + clients: [], + }) + const fiber = (uid: unknown, children: unknown[] = [{ kind: 'context', children: [] }]): unknown => ({ + kind: 'fiber', + uid, + children, + }) + const invalid = [ + [fiber(1), 'root must be a Context'], + [null, 'known kind'], + [{ kind: 'unknown', children: [] }, 'known kind'], + [{ kind: 'context', children: {} }, 'children must be an array'], + [{ kind: 'context', children: [fiber(0)] }, 'invalid Cordis runtime Fiber'], + [{ kind: 'context', children: [fiber(1, [])] }, 'invalid Cordis runtime Fiber'], + [{ kind: 'context', children: [fiber(1, [fiber(2)])] }, 'Fiber child must be a Context'], + [{ kind: 'context', children: [fiber(1), fiber(1)] }, 'repeats a Fiber uid'], + ] as const + for (const [root, message] of invalid) expect(() => parseCordisRuntimeTree(withRoot(root))).toThrow(message) + + let deep: unknown = { kind: 'context', children: [] } + for (let depth = 0; depth < 258; depth++) deep = { kind: 'context', children: [deep] } + expect(() => parseCordisRuntimeTree(withRoot(deep))).toThrow('depth limit') + }) +}) + +describe('Cordis snapshot model', () => { + it('parses a complete Context/Fiber tree and its object references', () => { + const snapshot = routedSnapshot() + expect(parseCordisTreeSnapshot(snapshot, 10)).toEqual(snapshot) + expect(parseInspectorObjectReference({ registryId: 'registry-1', handle: 'context-1' })).toEqual({ + registryId: 'registry-1', + handle: 'context-1', + }) + }) + + it.each([ + [{ ...routedSnapshot(), schemaVersion: 1 }, 'invalid Cordis tree header'], + [{ ...routedSnapshot(), revision: 0 }, 'invalid Cordis tree header'], + [{ ...routedSnapshot(), truncated: 'no' }, 'invalid Cordis tree header'], + [{ ...routedSnapshot(), root: routedFiber(1, 'fiber-root', routedContext('fiber-child')) }, 'root must be a Context'], + [{ ...routedSnapshot(), root: null }, 'known kind'], + [{ ...routedSnapshot(), root: { kind: 'unknown', objectHandle: 'bad', children: [] } }, 'known kind'], + [{ ...routedSnapshot(), root: { kind: 'context', objectHandle: 'bad', children: {} } }, 'children must be an array'], + [{ ...routedSnapshot(), root: routedContext('same', [routedContext('same')]) }, 'repeats an object handle'], + [{ ...routedSnapshot(), root: routedContext('root', [routedFiber(0, 'fiber', routedContext('child'))]) }, 'positive safe integer'], + [{ ...routedSnapshot(), root: routedContext('root', [routedFiber(1, 'fiber', routedContext('child'), [])]) }, 'exactly one Context'], + [{ ...routedSnapshot(), root: routedContext('root', [ + routedFiber(1, 'fiber-1', routedContext('child-1')), + routedFiber(1, 'fiber-2', routedContext('child-2')), + ]) }, 'repeats a Fiber uid'], + [{ ...routedSnapshot(), root: routedContext('root', [ + routedFiber(1, 'fiber-1', routedContext('unused'), [routedFiber(2, 'fiber-2', routedContext('child'))]), + ]) }, 'Fiber child must be a Context'], + ])('rejects malformed routed snapshots %#', (value, message) => { + expect(() => parseCordisTreeSnapshot(value, 10)).toThrow(message) + }) + + it('enforces node and depth limits', () => { + expect(() => parseCordisTreeSnapshot(routedSnapshot(), 1)).toThrow('exceeds 1 nodes') + let deep: unknown = routedContext('leaf') + for (let depth = 0; depth < 258; depth++) deep = routedContext(`depth-${String(depth)}`, [deep]) + expect(() => parseCordisTreeSnapshot({ ...routedSnapshot(), root: deep }, 1_000)).toThrow('depth limit') + }) +}) + +describe('Cordis runtime projection', () => { + it('removes routing fields from context-only and Fiber nodes in disconnected Client trees', () => { + const projected = projectCordisRuntimeTree({ + host: null, + clients: [{ + source: { sourceId: 'client-1', kind: 'client', label: 'Client' }, + connection: { state: 'disconnected', reason: 'offline' }, + snapshot: routedSnapshot(routedContext('root', [ + routedContext('nested'), + routedFiber(1, 'fiber', routedContext('owned')), + ])) as unknown as CordisTreeSnapshot, + }], + }) + + expect(projected).toEqual({ + schemaVersion: 0, + host: null, + clients: [{ + source: { sourceId: 'client-1', kind: 'client', label: 'Client' }, + connection: { state: 'disconnected', reason: 'offline' }, + revision: 1, + truncated: false, + root: { + kind: 'context', + children: [ + { kind: 'context', children: [] }, + { kind: 'fiber', uid: 1, children: [{ kind: 'context', children: [] }] }, + ], + }, + }], + }) + }) +}) + +describe('Cordis object registry', () => { + it('retains stable identities, recognizes wrappers, and rolls generations atomically', () => { + const registry = new RealmObjectRegistry() + const value = {} + const first = registry.begin() + const reference = first.retain(value) + expect(first.retain(value)).toEqual(reference) + first.commit() + first.commit() + expect(registry.resolve(reference.handle)).toBe(value) + expect(registry.identify(value)).toEqual(reference) + expect(identifyRealmObject(value)).toEqual(reference) + expect(globalThis.eval(realmObjectExpression(reference))).toBe(value) + + const wrapper = Object.create(value) as { then?: unknown } + wrapper.then = undefined + expect(registry.identify(wrapper)).toEqual(reference) + let deepWrapper: object = value + for (let depth = 0; depth < 10; depth++) { + deepWrapper = Object.assign(Object.create(deepWrapper) as object, { then: undefined }) + } + expect(registry.identify(deepWrapper)).toBeUndefined() + expect(registry.identify(null)).toBeUndefined() + expect(registry.identify(Object.create(value) as object)).toBeUndefined() + expect(registry.identify(new Proxy({}, { ownKeys: () => { throw new Error('blocked') } }))).toBeUndefined() + expect(identifyRealmObject({})).toBeUndefined() + + expect(() => first.retain({})).toThrow('already committed') + expect(() => { first.release(reference.handle) }).toThrow('already committed') + const second = registry.begin() + second.release(reference.handle) + second.commit() + expect(registry.resolve(reference.handle)).toBeUndefined() + registry.close() + registry.close() + expect(() => registry.begin()).toThrow('registry is disposed') + }) +}) + +function realm( + sourceId: string, + kind: 'host' | 'client', + connection: unknown, + overrides: Record = {}, +): Record { + return { + source: { sourceId, kind, label: sourceId }, + connection, + revision: 1, + truncated: false, + root: { kind: 'context', children: [{ kind: 'fiber', uid: 1, children: [{ kind: 'context', children: [] }] }] }, + ...overrides, + } +} + +function routedContext(objectHandle: string, children: unknown[] = []): Record { + return { kind: 'context', objectHandle, children } +} + +function routedFiber( + uid: unknown, + objectHandle: string, + context: unknown, + children: unknown[] = [context], +): Record { + return { kind: 'fiber', uid, objectHandle, children } +} + +function routedSnapshot(root: unknown = routedContext('context-1', [ + routedFiber(1, 'fiber-1', routedContext('context-2')), +])): Record { + return { + schemaVersion: 0, + revision: 1, + objectRegistryId: 'registry-1', + root, + truncated: false, + } +} diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 1f014d4311..22ceac488b 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -4,11 +4,13 @@ import { Context } from '@deepseek-ai/cordis' import WebSocket, { type RawData } from 'ws' import { afterEach, describe, expect, it, vi } from 'vitest' import { CordisTreeCollector } from '../src/shared/cordis/collector.ts' +import { observeCordisTree } from '../src/shared/cordis/observer.ts' import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' import { publishCordisTree as publishHostCordisTree } from '../src/host/inspection/cordis.ts' import { parseCordisTreeSnapshot, type CordisTreeNode } from '../src/shared/cordis/snapshot.ts' import { inspectorId } from '../src/shared/bridge/ids.ts' import type { InspectorJsonValue } from '../src/shared/json.ts' +import { jsonByteLength } from '../src/shared/json.ts' import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' import { InspectorClientFixture } from './fixtures/client-source.host.ts' @@ -133,6 +135,106 @@ describe('Cordis tree inspection', () => { await fiber.dispose() }) + it('marks snapshots truncated when a Context ancestry exceeds the traversal limit', async () => { + const root = new Context() + let context = root + for (let depth = 0; depth < 102; depth++) context = context.isolate(`depth-${String(depth)}`) + const fiber = context.plugin({ name: 'deep-child', apply() {} }) + await fiber.await() + const collector = new CordisTreeCollector(root, { maxNodes: 1_000, maxBytes: 1024 * 1024 }) + + expect(collector.snapshot().truncated).toBe(true) + + collector.close() + await fiber.dispose() + }) + + it('bounds snapshots by node count and encoded byte size', async () => { + const root = new Context() + const parent = root.isolate('parent') + const child = parent.isolate('child') + const fiber = child.plugin({ name: 'bounded-child', apply() {} }) + await fiber.await() + const completeCollector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) + const complete = completeCollector.snapshot() + const rootOnlyBytes = jsonByteLength({ + ...complete, + objectRegistryId: 'x'.repeat(complete.objectRegistryId.length), + root: { ...complete.root, children: [] }, + truncated: true, + }) + completeCollector.close() + + const nodeBound = new CordisTreeCollector(root, { maxNodes: 1, maxBytes: 64 * 1_024 }) + expect(nodeBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } }) + nodeBound.close() + + const directRoot = new Context() + const directFiber = directRoot.plugin({ name: 'direct-child', apply() {} }) + await directFiber.await() + const fiberBound = new CordisTreeCollector(directRoot, { maxNodes: 2, maxBytes: 64 * 1_024 }) + expect(fiberBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } }) + fiberBound.close() + + const byteBound = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: rootOnlyBytes }) + expect(byteBound.snapshot()).toMatchObject({ truncated: true, root: { children: [] } }) + byteBound.close() + + const impossible = new CordisTreeCollector(root, { maxNodes: 0, maxBytes: 1 }) + expect(() => impossible.snapshot()).toThrow('maxNodes cannot retain the root Context') + impossible.close() + + const rootTooLarge = new CordisTreeCollector(new Context(), { maxNodes: 2, maxBytes: 1 }) + expect(() => rootTooLarge.snapshot()).toThrow('Cordis root exceeds the source-frame byte limit') + rootTooLarge.close() + await directFiber.dispose() + await fiber.dispose() + }) + + it('coalesces Cordis notifications and ignores a queued publication after disposal', async () => { + const root = new Context() + const listener = vi.fn() + const dispose = observeCordisTree(root, listener, { maxNodes: 100, maxBytes: 64 * 1_024 }) + expect(listener).toHaveBeenCalledTimes(1) + + root.emit('internal/plugin', root.fiber) + root.emit('internal/plugin', root.fiber) + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(2) + + root.emit('internal/plugin', root.fiber) + dispose() + dispose() + await Promise.resolve() + expect(listener).toHaveBeenCalledTimes(2) + }) + + it('ignores disposed Fibers and non-Context listener owners while unwrapping Cordis shadows', async () => { + const root = new Context() + const fiber = root.plugin({ name: 'temporarily-disposed', apply() {} }) + await fiber.await() + const runtimeFiber = fiber.ctx.fiber + const uidDescriptor = Object.getOwnPropertyDescriptor(runtimeFiber, 'uid') + Object.defineProperty(runtimeFiber, 'uid', { ...uidDescriptor, value: null }) + const hooks = root.events._hooks as unknown as Record | undefined> + const probe = Symbol('inspector-collector-probe') + const empty = Symbol('inspector-collector-empty') + const shadow = Object.create(root) as object + Object.defineProperty(shadow, Symbol.for('cordis.shadow'), { value: true }) + hooks[probe] = [{ ctx: {} }, { ctx: shadow }, { ctx: runtimeFiber.ctx }] + hooks[empty] = undefined + const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) + try { + expect(collector.snapshot().root.kind).toBe('context') + } finally { + collector.close() + Reflect.deleteProperty(hooks, probe) + Reflect.deleteProperty(hooks, empty) + if (uidDescriptor !== undefined) Object.defineProperty(runtimeFiber, 'uid', uidDescriptor) + await fiber.dispose() + } + }) + it('freezes a disconnected snapshot and replaces it with the reconnect generation', () => { const root = new Context() const collector = new CordisTreeCollector(root, { maxNodes: 100, maxBytes: 64 * 1_024 }) diff --git a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts index c078b4e23c..69652b869d 100644 --- a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts +++ b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts @@ -12,6 +12,7 @@ describe('full fetch observer', () => { afterEach(async () => { await observer?.stop() observer = undefined + vi.restoreAllMocks() if (originalDescriptor === undefined) Reflect.deleteProperty(globalThis, 'fetch') else Object.defineProperty(globalThis, 'fetch', originalDescriptor) }) @@ -79,7 +80,7 @@ describe('full fetch observer', () => { expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true }) }) - it('retains a truncated response when the caller cancels after response headers', async () => { + it('retains captured bytes and reports cancellation after response headers', async () => { const records: InspectorRecordInput[] = [] Object.defineProperty(globalThis, 'fetch', { value: vi.fn(async (request: Request) => new Response(new ReadableStream({ @@ -103,15 +104,14 @@ describe('full fetch observer', () => { const response = await fetch('https://example.test/cancel-body', { signal: abort.signal }) abort.abort() await expect(response.text()).rejects.toThrow() - await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/error')).toBe(true) }) expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('first') - expect(payload(records, 'fetch/end')).toMatchObject({ - capturedBytes: 5, - responseBodyTruncated: true, - responseCaptureError: 'AbortError: aborted', + expect(payload(records, 'fetch/error')).toMatchObject({ + message: 'AbortError: aborted', + canceled: true, }) - expect(records.some(record => record.topic === 'fetch/error')).toBe(false) + expect(records.some(record => record.topic === 'fetch/end')).toBe(false) }) it('reports a fetch rejected before response headers as a canceled request', async () => { @@ -140,6 +140,257 @@ describe('full fetch observer', () => { expect(records.some(record => record.topic === 'fetch/response')).toBe(false) expect(records.some(record => record.topic === 'fetch/end')).toBe(false) }) + + it('reports non-cancellation fetch failures without manufacturing a canceled flag', async () => { + const records: InspectorRecordInput[] = [] + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.reject(new Error('connection failed'))), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + await expect(fetch('https://example.test/failure')).rejects.toThrow('connection failed') + expect(payload(records, 'fetch/error')).toMatchObject({ message: 'Error: connection failed', canceled: false }) + }) + + it('records request and response clone failures without replacing the caller response', async () => { + const records: InspectorRecordInput[] = [] + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.resolve(new Response('response'))), + writable: true, + configurable: true, + }) + const requestClone = vi.spyOn(Request.prototype, 'clone').mockImplementationOnce(() => { + throw new Error('request clone failed') + }) + const responseClone = vi.spyOn(Response.prototype, 'clone').mockImplementationOnce(() => { + throw new Error('response clone failed') + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + const response = await fetch('https://example.test/clone-failure', { method: 'POST', body: 'request' }) + expect(await response.text()).toBe('response') + expect(payload(records, 'fetch/request-body-end')).toMatchObject({ captureError: 'Error: request clone failed' }) + expect(payload(records, 'fetch/end')).toMatchObject({ responseCaptureError: 'Error: response clone failed' }) + requestClone.mockRestore() + responseClone.mockRestore() + }) + + it('handles responses without bodies and keeps stop idempotent when fetch is replaced', async () => { + const records: InspectorRecordInput[] = [] + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.resolve(new Response(null, { status: 204 }))), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + const replacement = vi.fn() + + await fetch('https://example.test/no-content') + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) + Object.defineProperty(globalThis, 'fetch', { value: replacement, writable: true, configurable: true }) + const firstStop = observer.stop() + expect(observer.stop()).toBe(firstStop) + await firstStop + expect(globalThis.fetch).toBe(replacement) + }) + + it('rejects installation without a callable global fetch', () => { + Object.defineProperty(globalThis, 'fetch', { value: undefined, writable: true, configurable: true }) + expect(() => installFetchObserver({ publish: vi.fn() }, { + maxRequestBodyBytes: 1, + maxResponseBodyBytes: 1, + maxChunkBytes: 1, + })).toThrow('globalThis.fetch is unavailable') + }) + + it('rejects an accessor fetch property', () => { + const nativeFetch = globalThis.fetch + Object.defineProperty(globalThis, 'fetch', { + configurable: true, + get: () => nativeFetch, + }) + expect(() => installFetchObserver({ publish: vi.fn() }, { + maxRequestBodyBytes: 1, + maxResponseBodyBytes: 1, + maxChunkBytes: 1, + })).toThrow('globalThis.fetch is an accessor') + }) + + it('contains publisher failures from asynchronous body completion', async () => { + let endAttempted = false + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.resolve(new Response('response'))), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string): void { + if (topic !== 'fetch/end') return + endAttempted = true + throw new Error('publisher closed') + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + await fetch('https://example.test/publisher-failure') + await vi.waitFor(() => { expect(endAttempted).toBe(true) }) + await expect(observer.stop()).resolves.toBeUndefined() + }) + + it('cancels an active clone reader when the observer stops', async () => { + const records: InspectorRecordInput[] = [] + let settleRead: ((value: ReadableStreamReadResult) => void) | undefined + const reader = { + read: vi.fn(async () => await new Promise>((resolve) => { + settleRead = resolve + })), + cancel: vi.fn(() => { + settleRead?.({ done: true, value: undefined }) + return Promise.reject(new Error('cancel already observed')) + }), + releaseLock: vi.fn(), + } + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.resolve(new Response('caller response'))), + writable: true, + configurable: true, + }) + vi.spyOn(Response.prototype, 'clone').mockReturnValueOnce({ + body: { getReader: () => reader }, + } as unknown as Response) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + await fetch('https://example.test/pending-body') + await observer.stop() + expect(reader.cancel).toHaveBeenCalled() + expect(payload(records, 'fetch/end')).toMatchObject({ + responseCaptureError: 'inspector stopped during body capture', + }) + }) + + it('contains a rejected reader cancellation after reaching the body limit', async () => { + const records: InspectorRecordInput[] = [] + const reader = { + read: vi.fn() + .mockResolvedValueOnce({ done: false, value: Buffer.from('oversized') }) + .mockResolvedValue({ done: true, value: undefined }), + cancel: vi.fn(() => Promise.reject(new Error('cancel failed'))), + releaseLock: vi.fn(), + } + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn(() => Promise.resolve(new Response('caller response'))), + writable: true, + configurable: true, + }) + vi.spyOn(Response.prototype, 'clone').mockReturnValueOnce({ + body: { getReader: () => reader }, + } as unknown as Response) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1, maxChunkBytes: 1 }) + + await fetch('https://example.test/body-limit') + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) + expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 1, responseBodyTruncated: true }) + expect(reader.cancel).toHaveBeenCalledWith('inspector body capture limit reached') + }) + + it('renders non-Error rejection values without allowing hostile coercion to escape', async () => { + const records: InspectorRecordInput[] = [] + const plainFailure: unknown = 'plain failure' + const unrenderable = { toString: () => { throw new Error('cannot stringify') } } + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn() + .mockImplementationOnce(async () => { throw plainFailure }) + .mockImplementationOnce(async () => { throw unrenderable }), + writable: true, + configurable: true, + }) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + await expect(fetch('https://example.test/plain-failure')).rejects.toBe('plain failure') + await expect(fetch('https://example.test/unrenderable-failure')).rejects.toBe(unrenderable) + expect(records.filter(record => record.topic === 'fetch/error').map(record => record.payload)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ message: 'plain failure', canceled: false }), + expect.objectContaining({ message: 'unrenderable fetch error', canceled: false }), + ])) + }) + + it('restores an inherited fetch without leaving an own property', async () => { + const prototype = Object.getPrototypeOf(globalThis) as object + const inheritedDescriptor = Object.getOwnPropertyDescriptor(prototype, 'fetch') + const nativeFetch = originalDescriptor?.value as typeof fetch + Reflect.deleteProperty(globalThis, 'fetch') + Object.defineProperty(prototype, 'fetch', { value: nativeFetch, writable: true, configurable: true }) + try { + observer = installFetchObserver({ publish: vi.fn() }, { + maxRequestBodyBytes: 1_024, + maxResponseBodyBytes: 1_024, + maxChunkBytes: 4, + }) + await observer.stop() + expect(Object.hasOwn(globalThis, 'fetch')).toBe(false) + } finally { + if (inheritedDescriptor === undefined) Reflect.deleteProperty(prototype, 'fetch') + else Object.defineProperty(prototype, 'fetch', inheritedDescriptor) + } + }) + + it('reports request clone read errors and non-abort DOM failures', async () => { + const records: InspectorRecordInput[] = [] + const requestReadFailure: unknown = 'request read failed' + const reader = { + read: vi.fn(async () => { throw requestReadFailure }), + cancel: vi.fn(() => Promise.resolve()), + releaseLock: vi.fn(), + } + Object.defineProperty(globalThis, 'fetch', { + value: vi.fn() + .mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockRejectedValueOnce(new DOMException('network failed', 'NetworkError')), + writable: true, + configurable: true, + }) + vi.spyOn(Request.prototype, 'clone').mockReturnValueOnce({ + body: { getReader: () => reader }, + } as unknown as Request) + observer = installFetchObserver({ + publish(topic: string, payload: InspectorJsonValue, monotonicMs = performance.now()) { + records.push({ topic, payload, monotonicMs }) + }, + }, { maxRequestBodyBytes: 1_024, maxResponseBodyBytes: 1_024, maxChunkBytes: 4 }) + + await fetch('https://example.test/request-read-failure') + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/request-body-end')).toBe(true) }) + expect(payload(records, 'fetch/request-body-end')).toMatchObject({ captureError: 'request read failed' }) + await expect(fetch('https://example.test/network-failure')).rejects.toThrow('network failed') + expect(records.filter(record => record.topic === 'fetch/error').at(-1)?.payload) + .toMatchObject({ canceled: false }) + }) }) function payload(records: readonly InspectorRecordInput[], topic: string): Record { diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts index 79fcfe60ea..5b214c7fd5 100644 --- a/packages/experimental/inspector/tests/integration.host.spec.ts +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -1,6 +1,7 @@ /** Host-driven integration over an isolated Client fixture. */ import { createServer, type Server } from 'node:http' +import { createContext, runInContext } from 'node:vm' import WebSocket, { type RawData } from 'ws' import { afterEach, describe, expect, it, vi } from 'vitest' import { startInspector, type InspectorHandle } from '../src/host/bridge/controller.ts' @@ -250,6 +251,62 @@ describe('experimental Inspector real Worker', () => { expect((await secondCdp.call('Runtime.getProperties', { objectId: disabledObjectId })).error).toBeDefined() }) + it('cancels Client Runtime work when the Worker deadline expires', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, clientRuntimeTimeoutMs: 20 }) + client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Timeout Client' }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + const contextId = await clientContext(cdp) + + const timedOut = await cdp.call('Runtime.evaluate', { + expression: 'new Promise(() => {})', + contextId, + awaitPromise: true, + }) + expect(timedOut.error?.message).toContain('timed out after 20ms') + expect((await cdp.call('Runtime.evaluate', { + expression: '42', + contextId, + returnByValue: true, + })).result?.result).toMatchObject({ type: 'number', value: 42 }) + }) + + it('preserves native Host execution-context selectors', async () => { + inspector = await startInspector({ port: 0, captureFetch: false }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Runtime.enable') + const context = createContext({}, { name: 'Inspector VM Context' }) + runInContext('globalThis.vmMarker = "selected-vm"; let vmLexicalMarker = 1', context) + + let contextId: number | undefined + let uniqueContextId: string | undefined + await vi.waitFor(() => { + const created = runtimeContexts(cdp!).find(candidate => candidate.name === 'Inspector VM Context') + contextId = created?.id as number | undefined + uniqueContextId = created?.uniqueId as string | undefined + expect(contextId).toBeTypeOf('number') + expect(uniqueContextId).toBeTypeOf('string') + }) + const evaluated = await cdp.call('Runtime.evaluate', { + expression: 'globalThis.vmMarker', + contextId, + returnByValue: true, + }) + expect(evaluated.result?.result).toMatchObject({ type: 'string', value: 'selected-vm' }) + expect((await cdp.call('Runtime.evaluate', { + expression: 'globalThis.vmMarker', + uniqueContextId, + returnByValue: true, + })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' }) + expect((await cdp.call('Runtime.callFunctionOn', { + executionContextId: contextId, + functionDeclaration: 'function () { return globalThis.vmMarker }', + returnByValue: true, + })).result?.result).toMatchObject({ type: 'string', value: 'selected-vm' }) + expect((await cdp.call('Runtime.globalLexicalScopeNames', { executionContextId: contextId })).result?.names) + .toContain('vmLexicalMarker') + }) + it('uses the same Runtime value model for Host and Client realms', async () => { inspector = await startInspector({ port: 0, captureFetch: false }) client = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Compatibility Client' }) @@ -397,7 +454,7 @@ describe('experimental Inspector real Worker', () => { callFrameId: 'client:unsupported-frame', expression: '1', })).error?.message).toContain('Client native debugging is unavailable') - }) + }, 15_000) it('projects full Host fetch data through the Network domain', async () => { server = createServer((request, response) => { diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts index a658571a8d..f9fddf5799 100644 --- a/packages/experimental/inspector/tests/network.host.spec.ts +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -6,6 +6,7 @@ import { NetworkStore } from '../src/worker/inspection/network-store.ts' import { inspectorId } from '../src/shared/bridge/ids.ts' import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' import type { IngestedInspectorRecord } from '../src/worker/bridge/hub.ts' +import type { InspectorJsonValue } from '../src/shared/json.ts' const source: InspectorSourceDescriptor = { sourceId: inspectorId<'InspectorSourceId'>('host-network', 'sourceId'), @@ -161,6 +162,201 @@ describe('Inspector Network domain', () => { expect(replay).toHaveBeenNthCalledWith(2, 'Network.responseReceived', expect.any(Object)) expect(replay).toHaveBeenNthCalledWith(3, 'Network.loadingFinished', expect.any(Object)) }) + + it('retains partial response bytes while reporting a post-header cancellation as failed', () => { + const sendEvent = vi.fn() + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const network = new NetworkDomain(store) + network.enable({ sendEvent }) + const records = requestRecords('canceled', 'partial') + store.append(source, [ + ...records.slice(0, 3), + { + sequence: 4, + monotonicMs: 4, + topic: 'fetch/error', + payload: { requestId: 'canceled', message: 'AbortError: aborted', canceled: true }, + }, + ]) + + expect(sendEvent).toHaveBeenCalledWith('Network.loadingFailed', expect.objectContaining({ + requestId: requestId('canceled'), + type: 'Fetch', + errorText: 'AbortError: aborted', + canceled: true, + })) + expect(network.handle('Network.getResponseBody', { requestId: requestId('canceled') }, { sendEvent: vi.fn() })) + .toMatchObject({ + body: Buffer.from('partial').toString('base64'), + dshInspectorTruncated: true, + dshInspectorCaptureError: 'AbortError: aborted', + }) + }) + + it('retains request capture metadata and isolates malformed observations', () => { + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const observed: unknown[] = [] + store.subscribe(() => { throw new Error('broken observer') }) + const unsubscribe = store.subscribe((event) => { observed.push(event) }) + const start = requestRecords('metadata', 'response')[0]! + store.append(source, [ + { ...start, topic: 'ignored/topic' }, + { ...start, payload: null }, + start, + start, + { sequence: 2, monotonicMs: 2, topic: 'fetch/request-body-chunk', payload: { requestId: 'metadata', data: Buffer.from('body').toString('base64') } }, + { sequence: 3, monotonicMs: 3, topic: 'fetch/request-body-end', payload: { requestId: 'metadata', truncated: true, captureError: 'request capture failed' } }, + ]) + expect(store.requestBody(requestId('metadata'))).toMatchObject({ + bytes: Buffer.from('body'), + truncated: true, + captureError: 'request capture failed', + complete: false, + }) + expect(() => store.responseBody(requestId('metadata'))).toThrow('response headers have not arrived') + + store.append(source, [ + requestRecords('metadata', 'response')[1]!, + requestRecords('metadata', 'response')[2]!, + { + sequence: 4, + monotonicMs: 4, + topic: 'fetch/end', + payload: { + requestId: 'metadata', + capturedBytes: 8, + responseBodyTruncated: true, + responseCaptureError: 'response capture failed', + }, + }, + { + sequence: 5, + monotonicMs: 5, + topic: 'fetch/error', + payload: { requestId: 'metadata', message: 'late failure', canceled: false }, + }, + ]) + expect(store.responseBody(requestId('metadata'))).toMatchObject({ + bytes: Buffer.from('response'), + truncated: true, + captureError: 'response capture failed', + complete: true, + }) + expect(observed).toHaveLength(4) + unsubscribe() + store.dispose() + expect(() => store.requestBody(requestId('metadata'))).toThrow('No resource with given identifier') + expect(() => store.requestBody(1)).toThrow('Network requestId must be a string') + }) + + it('closes only active requests from the selected source and supports replacement', () => { + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const observed: Array<{ type: string; requestId?: string }> = [] + store.subscribe((event) => { observed.push(event) }) + const clientSource: InspectorSourceDescriptor = { + ...source, + sourceId: inspectorId<'InspectorSourceId'>('other-network', 'sourceId'), + generation: inspectorId<'InspectorSourceGeneration'>('other-generation', 'generation'), + kind: 'client', + } + store.append(source, requestRecords('complete', 'done')) + store.append(source, requestRecords('active', 'partial').slice(0, 3)) + store.append(clientSource, requestRecords('other', 'partial').slice(0, 3)) + + store.close(source, 'source closed') + expect(observed.filter(event => event.type === 'request-failed')).toEqual([ + expect.objectContaining({ requestId: requestId('active') }), + ]) + store.close(source, 'source closed again') + store.replace(clientSource, []) + expect(observed.filter(event => event.type === 'request-failed')).toHaveLength(2) + }) + + it('rejects malformed fetch fields without losing later valid records', () => { + const store = new NetworkStore({ maxRetainedRequests: 20, maxJournalBytes: 1_024 }) + const validStart = requestRecords('valid', 'ok')[0]! + const malformed: IngestedInspectorRecord[] = [ + { ...validStart, payload: null }, + { ...validStart, payload: { ...validStart.payload as object, requestId: 1 } }, + { ...validStart, payload: { ...validStart.payload as object, wallTimeMs: Number.POSITIVE_INFINITY } }, + { ...validStart, payload: { ...validStart.payload as object, headers: {} } }, + { ...validStart, payload: { ...validStart.payload as object, headers: [[1, 'value']] } }, + { ...validStart, payload: { ...validStart.payload as object, hasBody: 'yes' } }, + ] + store.append(source, [...malformed, validStart]) + const invalidPayloads: InspectorJsonValue[] = [ + { requestId: 'valid', data: '' }, + { requestId: 'valid', data: 'abc' }, + { requestId: 'valid', data: '!!!!' }, + { requestId: 'valid', data: 'ZE==' }, + ] + store.append(source, invalidPayloads.map((payload, index) => ({ + sequence: index + 2, + monotonicMs: index + 2, + topic: 'fetch/request-body-chunk', + payload, + }))) + store.append(source, [ + { sequence: 10, monotonicMs: 10, topic: 'fetch/request-body-end', payload: { requestId: 'valid', truncated: 'yes' } }, + { sequence: 11, monotonicMs: 11, topic: 'fetch/request-body-end', payload: { requestId: 'valid', truncated: false, captureError: 1 } }, + { sequence: 12, monotonicMs: 12, topic: 'fetch/response', payload: { requestId: 'valid', url: 'https://example.test', status: '200', statusText: 'OK', headers: [], mimeType: 'text/plain' } }, + { sequence: 13, monotonicMs: 13, topic: 'fetch/response', payload: { requestId: 'valid', url: 'https://example.test', status: 200, statusText: 'OK', headers: [['bad']], mimeType: 'text/plain' } }, + requestRecords('valid', 'ok')[1]!, + requestRecords('valid', 'ok')[2]!, + requestRecords('valid', 'ok')[3]!, + requestRecords('valid', 'ok')[3]!, + ]) + + expect(store.responseBody(requestId('valid')).bytes).toEqual(Buffer.from('ok')) + + const failedStart = requestRecords('failed-before-response', '')[0]! + store.append(source, [failedStart, { + sequence: 20, + monotonicMs: 20, + topic: 'fetch/error', + payload: { requestId: 'failed-before-response', message: 'connection failed', canceled: false }, + }]) + }) + + it('tracks zero-byte truncation and evicts a completed request before an active request', () => { + const store = new NetworkStore({ maxRetainedRequests: 1, maxJournalBytes: 1 }) + store.append(source, requestRecords('completed', 'a')) + const active = requestRecords('active', 'bc') + store.append(source, [ + active[0]!, + { + sequence: 2, + monotonicMs: 2, + topic: 'fetch/request-body-chunk', + payload: { requestId: 'active', data: Buffer.from('x').toString('base64') }, + }, + active[1]!, + active[2]!, + ]) + + expect(() => store.requestBody(requestId('completed'))).toThrow('No resource with given identifier') + expect(store.responseBody(requestId('active'))).toMatchObject({ + bytes: Buffer.alloc(0), + truncated: true, + complete: false, + }) + + store.append(source, [{ + sequence: 4, + monotonicMs: 4, + topic: 'fetch/request-body-chunk', + payload: { requestId: 'active', data: Buffer.from('d').toString('base64') }, + }]) + expect(store.requestBody(requestId('active'))).toMatchObject({ bytes: Buffer.from('x'), truncated: true }) + }) + + it('rejects a non-list header field without dropping the active request', () => { + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const start = requestRecords('headers', 'ok')[0]! + store.append(source, [{ ...start, payload: { ...start.payload as object, headers: null } }, start]) + + expect(store.requestBody(requestId('headers')).complete).toBe(false) + }) }) function requestRecords(localId: string, body: string): IngestedInspectorRecord[] { diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts index ea3cbd3e31..82a457d3cf 100644 --- a/packages/experimental/inspector/tests/plugin.client.spec.ts +++ b/packages/experimental/inspector/tests/plugin.client.spec.ts @@ -179,6 +179,61 @@ describe('experimental Inspector Client plugin', () => { await fiber.dispose() }) + it('cancels an outstanding Client Runtime operation without sending a late response', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + const fiber = ctx.plugin({ apply }) + await fiber.await() + const socket = FakeWebSocket.sockets[0]! + socket.open() + const open = JSON.parse(socket.sent[0]!) as { + source: { sourceId: string; generation: string } + } + socket.receive({ + v: 0, + t: 'source/accepted', + sourceId: open.source.sourceId, + generation: open.source.generation, + }) + socket.receive({ + v: 0, + t: 'client-runtime/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'devtools-cancel', + requestId: 'runtime-cancel', + command: { op: 'evaluate', expression: 'new Promise(() => {})', awaitPromise: true }, + }) + socket.receive({ + v: 0, + t: 'client-runtime/cancel', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'devtools-cancel', + requestId: 'runtime-cancel', + }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(socket.sent.map(value => JSON.parse(value) as { requestId?: string }) + .some(frame => frame.requestId === 'runtime-cancel')).toBe(false) + + socket.receive({ + v: 0, + t: 'client-runtime/request', + sourceId: open.source.sourceId, + generation: open.source.generation, + sessionId: 'devtools-cancel', + requestId: 'runtime-after-cancel', + command: { op: 'evaluate', expression: '42', returnByValue: true }, + }) + await vi.waitFor(() => { + expect(socket.sent.map(value => JSON.parse(value) as { requestId?: string }) + .some(frame => frame.requestId === 'runtime-after-cancel')).toBe(true) + }) + + await fiber.dispose() + }) + it('does not report queue loss again after a replacement absorbs it', async () => { globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket globalThis.__DSH_INSPECTOR__ = { ...bootstrap, maxQueuedRecords: 1 } @@ -299,4 +354,20 @@ describe('experimental Inspector Client plugin', () => { await expect(fiber).rejects.toThrow('Host bootstrap is missing') await fiber.dispose() }) + + it('closes the Client source when a later plugin registration fails', async () => { + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket + globalThis.__DSH_INSPECTOR__ = bootstrap + const ctx = new Context() + ctx.provide('inspector', { + publish: () => undefined, + cordis: { getTree: () => Promise.reject(new Error('unused test service')) }, + }) + + const fiber = ctx.plugin({ apply }) + await expect(fiber.await()).rejects.toThrow('service "inspector" has been registered') + expect(FakeWebSocket.sockets).toHaveLength(1) + expect(FakeWebSocket.sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) + await fiber.dispose() + }) }) diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index f69a9cc509..f6a28ea2a2 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -22,8 +22,8 @@ describe('experimental Inspector Host plugin', () => { }) it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => { - const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) context = new Context() + const log = vi.spyOn(context.logger, 'info').mockImplementation(() => undefined) context.provide('webServer', {} as WebServer) const fiber = context.plugin( { name, inject: [...inject], Config, apply }, diff --git a/packages/experimental/inspector/tests/protocol.host.spec.ts b/packages/experimental/inspector/tests/protocol.host.spec.ts index 1e538194e5..981d417747 100644 --- a/packages/experimental/inspector/tests/protocol.host.spec.ts +++ b/packages/experimental/inspector/tests/protocol.host.spec.ts @@ -124,6 +124,15 @@ describe('Inspector source protocol', () => { ...request, command: { ...request.command, unversionedExtension: true }, })).toThrow('unknown field') + + expect(parseWorkerSourceFrame({ + v: 0, + t: 'client-runtime/response-acknowledged', + sourceId: 'client-1', + generation: 'g-1', + sessionId: 'session-1', + requestId: 'request-1', + })).toMatchObject({ t: 'client-runtime/response-acknowledged', requestId: 'request-1' }) }) it('rejects invalid RemoteObject representations', () => { diff --git a/packages/experimental/inspector/tests/shared-validation.host.spec.ts b/packages/experimental/inspector/tests/shared-validation.host.spec.ts new file mode 100644 index 0000000000..dac4903c3d --- /dev/null +++ b/packages/experimental/inspector/tests/shared-validation.host.spec.ts @@ -0,0 +1,78 @@ +/** Shared JSON and exact-field validation behavior. */ + +import { describe, expect, it } from 'vitest' +import { inspectorId } from '../src/shared/identity.ts' +import { isJsonValue, isPlainObject, jsonByteLength, requireJsonObject } from '../src/shared/json.ts' +import { + exactKeys, + exactObject, + optionalBoolean, + optionalNonNegativeNumber, + optionalString, + wireId, +} from '../src/shared/validation.ts' + +describe('Inspector JSON values', () => { + it('accepts every lossless JSON category and measures UTF-8 bytes', () => { + const nullPrototype = Object.assign(Object.create(null) as Record, { value: '好' }) + expect([null, 'text', true, 1, [1, 'two'], { nested: [false] }, nullPrototype].every(isJsonValue)).toBe(true) + expect(jsonByteLength({ value: '好' })).toBe(Buffer.byteLength('{"value":"好"}')) + expect(isPlainObject({})).toBe(true) + expect(isPlainObject(nullPrototype)).toBe(true) + expect(requireJsonObject({ value: 1 }, 'payload')).toEqual({ value: 1 }) + }) + + it('rejects lossy primitives, cycles, exotic arrays, and accessor objects', () => { + const cyclic: Record = {} + cyclic.self = cyclic + const arrayWithField = [1] + Reflect.set(arrayWithField, 'extra', true) + const inheritedArray = Object.setPrototypeOf([1], null) as unknown + const symbolObject = { [Symbol('field')]: true } + const hidden = {} + Object.defineProperty(hidden, 'value', { value: 1, enumerable: false }) + const accessor = {} + Object.defineProperty(accessor, 'value', { get: () => 1, enumerable: true }) + const rejected = [ + undefined, () => undefined, Number.NaN, -0, cyclic, arrayWithField, + inheritedArray, new Date(), symbolObject, hidden, accessor, + ] + for (const value of rejected) { + expect(isJsonValue(value)).toBe(false) + } + expect(() => requireJsonObject([], 'payload')).toThrow('payload must be a JSON object') + expect(() => requireJsonObject(cyclic, 'payload')).toThrow('payload must be a JSON object') + expect(isPlainObject(null)).toBe(false) + expect(isPlainObject([])).toBe(false) + }) +}) + +describe('Inspector exact-field readers', () => { + it('accepts declared fields and optional values', () => { + const record = { text: 'value', enabled: true, timeout: 0 } + expect(exactObject(record, ['text', 'enabled', 'timeout'], 'record')).toBe(record) + expect(() => { exactKeys(record, ['text', 'enabled', 'timeout'], 'record') }).not.toThrow() + expect(optionalString(record, 'text')).toEqual({ text: 'value' }) + expect(optionalBoolean(record, 'enabled')).toEqual({ enabled: true }) + expect(optionalNonNegativeNumber(record, 'timeout')).toEqual({ timeout: 0 }) + expect(optionalString({}, 'text')).toEqual({}) + expect(optionalBoolean({}, 'enabled')).toEqual({}) + expect(optionalNonNegativeNumber({}, 'timeout')).toEqual({}) + expect(wireId<'ProbeId'>('probe', 'probeId')).toBe('probe') + expect(inspectorId<'ProbeId'>('probe', 'probeId')).toBe('probe') + }) + + it('rejects unknown, symbolic, and wrongly typed fields', () => { + expect(() => exactObject([], [], 'record')).toThrow('record must be an object') + expect(() => { exactKeys({ extra: true }, [], 'record') }).toThrow('unknown field') + expect(() => { exactKeys({ [Symbol('extra')]: true }, [], 'record') }).toThrow('unknown field') + expect(() => wireId<'ProbeId'>(1, 'probeId')).toThrow('probeId must be a string') + expect(() => inspectorId<'ProbeId'>('', 'probeId')).toThrow('1 to 256 characters') + expect(() => inspectorId<'ProbeId'>('x'.repeat(257), 'probeId')).toThrow('1 to 256 characters') + expect(() => optionalString({ text: 1 }, 'text')).toThrow('text must be a string') + expect(() => optionalBoolean({ enabled: 1 }, 'enabled')).toThrow('enabled must be a boolean') + for (const timeout of ['1', Number.NaN, -1]) { + expect(() => optionalNonNegativeNumber({ timeout }, 'timeout')).toThrow('non-negative finite number') + } + }) +}) diff --git a/packages/experimental/inspector/tests/source-buffer.host.spec.ts b/packages/experimental/inspector/tests/source-buffer.host.spec.ts index 41907763f4..0e72d6e6db 100644 --- a/packages/experimental/inspector/tests/source-buffer.host.spec.ts +++ b/packages/experimental/inspector/tests/source-buffer.host.spec.ts @@ -1,25 +1,41 @@ /** Worker-side source buffer behavior. */ -import { describe, expect, it } from 'vitest' +import { MessageChannel } from 'node:worker_threads' +import { describe, expect, it, vi } from 'vitest' +import { HostBridgePublisher } from '../src/host/bridge/publisher.ts' import { inspectorId } from '../src/shared/bridge/ids.ts' -import { InspectorSourceBuffer } from '../src/shared/bridge/buffer.ts' +import { InspectorSourceBuffer, type InspectorSourceBufferOptions } from '../src/shared/bridge/buffer.ts' +import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' const sourceId = inspectorId<'InspectorSourceId'>('source-buffer-test', 'sourceId') const generation = inspectorId<'InspectorSourceGeneration'>('generation-buffer-test', 'generation') +const source: InspectorSourceDescriptor = { + sourceId, + generation, + kind: 'host', + label: 'Host', + timeOriginMs: performance.timeOrigin, + capabilities: [], +} -function buffer(maxQueuedRecords = 2): InspectorSourceBuffer { +function buffer( + maxQueuedRecords = 2, + overrides: Partial = {}, +): InspectorSourceBuffer { return new InspectorSourceBuffer({ topics: ['*'], maxQueuedRecords, maxQueuedBytes: 32_768, maxRecordsPerFrame: 8, maxFrameBytes: 32_768, + ...overrides, }) } describe('Inspector source buffer', () => { it('absorbs pre-replacement queue loss exactly once', () => { const records = buffer(1) + expect(records.replacement(sourceId, generation)).toMatchObject({ nextSequence: 1, records: [] }) records.publish('test/event', { ordinal: 1 }, 1) records.publish('test/event', { ordinal: 2 }, 2) @@ -38,6 +54,106 @@ describe('Inspector source buffer', () => { const records = buffer() expect(() => { records.publish('', {}, 1) }).toThrow('topic must contain 1 to 128 characters') + expect(() => { records.publish('x'.repeat(129), {}, 1) }).toThrow('topic must contain 1 to 128 characters') + expect(() => { buffer(2, { topics: ['declared'] }).publish('undeclared', {}, 1) }) + .toThrow('source does not declare topic') expect(() => { records.publish('test/event', {}, Number.NaN) }).toThrow('monotonicMs must be finite') + const cyclic: Record = {} + cyclic.self = cyclic + expect(() => { records.publish('test/event', cyclic as never, 1) }).toThrow('lossless JSON data') + }) + + it('rejects oversized retained state without replacing the previous value', () => { + const records = buffer(4, { maxFrameBytes: 4_300 }) + records.setState('state', { value: 'kept' }, 1) + expect(() => { records.setState('state', { value: 'x'.repeat(1_000) }, 2) }) + .toThrow('source state exceeds the source-frame byte limit') + expect(() => { records.setState('other', { value: 'x'.repeat(1_000) }, 3) }) + .toThrow('source state exceeds the source-frame byte limit') + expect(records.replacement(sourceId, generation).records).toEqual([ + { topic: 'state', payload: { value: 'kept' }, monotonicMs: 1 }, + ]) + }) + + it('splits frames at record, byte, and sequence gaps and discards pending records', () => { + const records = buffer(10, { maxRecordsPerFrame: 2, maxFrameBytes: 4_300 }) + expect(records.hasPending).toBe(false) + records.publish('test/event', { value: 'a'.repeat(40) }, 1) + records.publish('test/event', { value: 'x'.repeat(1_000) }, 2) + records.publish('test/event', { value: 'b'.repeat(40) }, 3) + expect(records.hasPending).toBe(true) + + expect(records.takeBatch(sourceId, generation)).toMatchObject({ firstSequence: 1, records: [{ monotonicMs: 1 }] }) + expect(records.takeBatch(sourceId, generation)).toMatchObject({ + firstSequence: 3, + droppedBefore: 1, + records: [{ monotonicMs: 3 }], + }) + expect(records.takeBatch(sourceId, generation)).toBeUndefined() + + records.publish('test/event', { ordinal: 4 }, 4) + records.discardPending() + expect(records.hasPending).toBe(false) + + const byteSplit = buffer(10, { maxFrameBytes: 4_300 }) + byteSplit.publish('test/event', { value: 'a'.repeat(100) }, 1) + byteSplit.publish('test/event', { value: 'b'.repeat(100) }, 2) + expect(byteSplit.takeBatch(sourceId, generation)?.records).toHaveLength(1) + expect(byteSplit.takeBatch(sourceId, generation)?.records).toHaveLength(1) + }) + + it('drops queued records against the byte limit independently of the item limit', () => { + const records = buffer(10, { maxQueuedBytes: 120 }) + records.publish('test/event', { value: 'a'.repeat(40) }, 1) + records.publish('test/event', { value: 'b'.repeat(40) }, 2) + + expect(records.takeBatch(sourceId, generation)).toMatchObject({ + firstSequence: 2, + droppedBefore: 1, + records: [{ monotonicMs: 2 }], + }) + }) + + it('keeps at most one Host MessagePort observation batch in flight', async () => { + const channel = new MessageChannel() + const messages: unknown[] = [] + channel.port2.on('message', (message) => { messages.push(message) }) + channel.port2.start() + const publisher = new HostBridgePublisher(channel.port1, source, { + topics: ['*'], + maxQueuedRecords: 2, + maxQueuedBytes: 32_768, + maxRecordsPerFrame: 1, + maxFrameBytes: 32_768, + }) + try { + publisher.publish('test/event', { ordinal: 1 }) + publisher.flush() + publisher.publish('test/event', { ordinal: 2 }) + publisher.publish('test/event', { ordinal: 3 }) + await vi.waitFor(() => { expect(messages).toHaveLength(1) }) + const first = messages[0] as { firstSequence: number; records: Array<{ payload: unknown }> } + expect(first.records).toHaveLength(1) + expect(first.records[0]?.payload).toEqual({ ordinal: 1 }) + + publisher.acknowledge(first.firstSequence + first.records.length) + await vi.waitFor(() => { expect(messages).toHaveLength(2) }) + const second = messages[1] as { firstSequence: number; droppedBefore: number; records: Array<{ payload: unknown }> } + expect(second).toMatchObject({ + firstSequence: 2, + droppedBefore: 0, + records: [{ payload: { ordinal: 2 } }], + }) + publisher.acknowledge(second.firstSequence + second.records.length) + await vi.waitFor(() => { expect(messages).toHaveLength(3) }) + expect(messages[2]).toMatchObject({ + firstSequence: 3, + records: [{ payload: { ordinal: 3 } }], + }) + } finally { + publisher.close() + channel.port1.close() + channel.port2.close() + } }) }) diff --git a/vitest.config.ts b/vitest.config.ts index c0e346c1c4..2127545939 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -251,12 +251,26 @@ export default defineConfig({ // coverage lane exists. 'packages/experimental/webworker-runtime/src/**', 'packages/experimental/webworker-packer/src/*', - // Inspector behavior spans a Node Worker, the Host isolate, and a real - // browser realm. Its focused specs cover pure logic, while its Worker, - // Debugger, Chromium, and Loader suites run the assembled paths that - // the parent Vitest process cannot attribute. TODO(inspector): remove - // when the coverage lane can merge cross-realm V8 coverage. - 'packages/experimental/inspector/src/**', + // Inspector execution adapters run in a Node Worker, the Host native + // inspector session, or a browser realm, outside attributable parent + // Vitest coverage. + 'packages/experimental/inspector/src/client/**', + 'packages/experimental/inspector/src/host/bridge/**', + 'packages/experimental/inspector/src/host/cdp/**', + 'packages/experimental/inspector/src/worker/bridge/**', + 'packages/experimental/inspector/src/worker/cdp/**', + 'packages/experimental/inspector/src/worker/realms/**', + 'packages/experimental/inspector/src/worker/{entry,server}.ts', + // Keep already-complete Inspector modules under the per-file gate and + // enumerate the remaining direct-test debt instead of exempting src/**. + // TODO(inspector): close these branch gaps and remove the entries. + 'packages/experimental/inspector/src/host/plugin.ts', + 'packages/experimental/inspector/src/shared/bridge/{control-codec,rpc}.ts', + 'packages/experimental/inspector/src/shared/bridge/messages/observation.ts', + 'packages/experimental/inspector/src/shared/bridge/messages/query/codec.ts', + 'packages/experimental/inspector/src/shared/bridge/messages/runtime/{command-codec,console-frames,frames,value-codec}.ts', + 'packages/experimental/inspector/src/shared/bridge/messages/sources/{codec,frames}.ts', + 'packages/experimental/inspector/src/worker/inspection/{cordis-store,query-router,realm-store}.ts', 'packages/client/modules/src/client/system.ts', 'packages/client/hmr/src/client/index.ts', // Web config-tree boot round: the new host-side web-transport halves From c5f34e8ada46ffa939b00a8b47a809913bba10a7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:48:10 +0800 Subject: [PATCH 074/130] fix(inspector): preserve event stream replay order --- .../src/worker/inspection/network-store.ts | 29 +++---------------- .../inspector/tests/network.host.spec.ts | 11 +++++-- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/packages/experimental/inspector/src/worker/inspection/network-store.ts b/packages/experimental/inspector/src/worker/inspection/network-store.ts index 581c0d2bad..db9caf7bbd 100644 --- a/packages/experimental/inspector/src/worker/inspection/network-store.ts +++ b/packages/experimental/inspector/src/worker/inspection/network-store.ts @@ -69,10 +69,7 @@ export type NetworkStoreEvent = } | { readonly type: 'request-evicted'; readonly requestKey: string } -type JournalNetworkEvent = Exclude -type ReplayableNetworkEvent = Exclude +type JournalNetworkEvent = Exclude interface CapturedRequest { readonly key: string @@ -140,26 +137,8 @@ export class NetworkStore implements InspectorRecordConsumer { * Read retained request lifecycle events. * @returns Events in observation order. */ - replay(): readonly ReplayableNetworkEvent[] { - const replay: ReplayableNetworkEvent[] = [] - for (const event of this.journal) { - replay.push(event) - if (event.type !== 'response-received' || event.mimeType !== 'text/event-stream') continue - const request = this.requests.get(event.requestKey) as CapturedRequest - const messages = new InspectorEventSourceParser().push(Buffer.concat(request.responseBody)) - let eventId = 0 - for (const message of messages) { - replay.push({ - type: 'event-source-message', - requestKey: request.key, - requestId: request.requestId, - timestampMs: event.timestampMs, - ...message, - eventId: String(++eventId), - }) - } - } - return replay + replay(): readonly JournalNetworkEvent[] { + return this.journal } /** @@ -274,7 +253,7 @@ export class NetworkStore implements InspectorRecordConsumer { const bytes = this.appendBody(request, 'response', data) const byteLength = bytes.byteLength for (const message of request.eventSourceParser?.push(bytes) ?? []) { - this.emit({ + this.publish({ type: 'event-source-message', requestKey: key, requestId: request.requestId, diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts index f9fddf5799..69b34b440d 100644 --- a/packages/experimental/inspector/tests/network.host.spec.ts +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -129,9 +129,16 @@ describe('Inspector Network domain', () => { .filter(call => call[0] === 'Network.eventSourceMessageReceived') .map(call => call[1] as unknown)) .toEqual([ - expect.objectContaining({ eventName: 'message', eventId: '1', data: 'first' }), - expect.objectContaining({ eventName: 'update', eventId: '2', data: 'second\nline' }), + expect.objectContaining({ timestamp: 0.003, eventName: 'message', eventId: '1', data: 'first' }), + expect.objectContaining({ timestamp: 0.004, eventName: 'update', eventId: '2', data: 'second\nline' }), ]) + expect(replay.mock.calls.map(call => String(call[0]))).toEqual([ + 'Network.requestWillBeSent', + 'Network.responseReceived', + 'Network.eventSourceMessageReceived', + 'Network.eventSourceMessageReceived', + 'Network.loadingFinished', + ]) }) it('bounds active request metadata and does not retain per-chunk events for replay', () => { From d6245fc25495f0e8c09039ea93e5297bb2f674af Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 07:13:58 +0800 Subject: [PATCH 075/130] fix(inspector): update Cordis DOM incrementally --- ...4-cordis-runtime-tree-inspection.i18n.yaml | 4 +- ...26-08-24-cordis-runtime-tree-inspection.md | 10 +- ...08-24-cordis-runtime-tree-inspection.zh.md | 10 +- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 2 + packages/experimental/inspector/README.zh.md | 2 + .../src/worker/cdp/domains/dom/index.ts | 2 +- .../src/worker/cdp/domains/dom/model.ts | 122 ++++++++++++++--- .../src/worker/cdp/domains/dom/session.ts | 99 +++++++++++++- .../inspector/tests/cordis-tree.host.spec.ts | 127 +++++++++++++++++- .../tests/fixtures/client-source.client.ts | 27 +++- .../tests/fixtures/client-source.host.ts | 15 +++ 12 files changed, 383 insertions(+), 41 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml index baaec4c1f1..c7ce5ddb76 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.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-24-cordis-runtime-tree-inspection.md -2026-08-24-cordis-runtime-tree-inspection.md: 49a0bb4de34cf2a3c9f2943b432a62051ae85a9d -2026-08-24-cordis-runtime-tree-inspection.zh.md: b08b8fcb8d2bbed624be4dd5273406c856393e62 +2026-08-24-cordis-runtime-tree-inspection.md: 9784018a050bdb27e791a44e1cd342a31cec42c0 +2026-08-24-cordis-runtime-tree-inspection.zh.md: 05b9b0d4387447b7c8df05b7c7e771e96c767bd9 diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md index 49a0bb4de3..9784018a05 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.md @@ -39,7 +39,7 @@ The identities are intentionally distinct: - Fiber `uid` comes from Cordis. Context currently has no Cordis-owned id and the Inspector does not expose a generated substitute. - `InspectorObjectReference` is an opaque realm-local handle resolving a tree node to its live Context or Fiber. Snapshots carry the handle for routing, never as a semantic id or DOM attribute. - `BackendNodeId` is assigned by the Worker to one retained `(source id, source generation, object reference)` and is shared by DevTools connections while that generation's snapshot is retained. -- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It is discarded on `DOM.documentUpdated` or connection close. +- `NodeId` is assigned per DevTools connection when a node enters that frontend's document. It remains stable while the corresponding backend node is retained and is discarded when that node leaves the tree, on the rare full-document fallback, or when the connection closes. - `RemoteObjectId` is assigned by the selected Runtime session when `DOM.resolveNode` exposes the live object. It remains scoped to that DevTools connection and object group. `sourceId` identifies one Client runtime instance and remains stable across its automatic transport reconnects; `generation` identifies one WebSocket admission. Disconnect removes the synthetic context from the Console with `Runtime.executionContextDestroyed`. Reconnection announces a fresh CDP execution-context id because the destroyed id and its RemoteObjects cannot be reused, but this does not imply that the browser's underlying JavaScript realm was recreated. @@ -62,7 +62,7 @@ Sources publish the Cordis tree as retained state rather than an event history. Closing a source changes its stored tree from connected to disconnected instead of deleting the last snapshot. Object lookup excludes disconnected trees, so the snapshot remains inspectable as data without retaining or reviving a live Context, Fiber, or Runtime object. A replacement from the same source id and a new transport generation atomically restores the connected state. The configurable disconnected-tree limit evicts the oldest retained snapshots. -Accepted tree replacements emit `DOM.documentUpdated` and require the frontend to pull a fresh document. A disconnect that does not evict another tree invalidates object routes without changing the DOM document, preserving the loaded tree, expansion, and selection. Connection state remains in the inspection model until its Elements presentation is designed. Retention eviction falls back to `DOM.documentUpdated`. Further incremental tree diffs can be added behind `CordisDomSession` without changing collectors, snapshots, or model consumers. +Accepted source snapshots rebuild the connection-neutral document and are diffed by stable backend node identity. A revision-only replacement emits no DOM event. Child insertion and removal use `DOM.childNodeInserted` and `DOM.childNodeRemoved`; attribute changes use their corresponding DOM events; sibling reorder falls back to `DOM.setChildNodes` for that parent only. Reusing one backend identity for a different node kind is the sole `DOM.documentUpdated` fallback. A disconnect invalidates object routes without changing the retained DOM tree, preserving expansion and selection; retention eviction removes only the evicted `` node. ## CDP projection @@ -97,7 +97,7 @@ The read-only adapter implements document retrieval, child requests, node descri - `DOM.resolveNode` and `DOM.requestNode` round-trip Context and Fiber identities without sharing object ids across DevTools connections or source generations. - A Context or Fiber returned by Runtime evaluation is node-branded and can be revealed in Elements. - Disconnect destroys the Client execution context and its RemoteObjects while retaining the last Elements tree unchanged; a new transport generation replaces it after a complete snapshot arrives. -- Reconnect and resnapshot replay the latest tree state; malformed or oversized replacements do not replace the last valid snapshot. +- Reconnect and resnapshot replay the latest tree state; unchanged snapshots emit no DOM mutation, while structural changes update only their affected parent or node. Malformed or oversized replacements do not replace the last valid snapshot. - The stored snapshot and query API contain no CDP types and can support a future model-facing adapter unchanged. ## Consequences @@ -106,8 +106,8 @@ Cordis exposes no complete global Context registry. The collector can recover co Object recognition adds a Runtime round trip for each Host object that requires semantic identification. An annotation failure leaves an ordinary RemoteObject rather than breaking Runtime or Debugger delivery. Client Console observation preserves the original method result and schedules serialization afterward; each enabled DevTools session receives independently retained handles, so recognition never blocks the page call or shares objects between connections. -Full-tree replacement is simpler than incremental source mutations but can become expensive in very large runtimes. Node and byte limits preserve a valid prefix and report truncation; a later delta protocol can replace the transport without changing the snapshot model. +Sources continue to publish complete snapshots, keeping one shared Host/Client collector and allowing recovery after dropped observations. The Worker pays the snapshot comparison cost, then emits incremental CDP DOM mutations so unchanged revisions do not reset the Elements document. Node and byte limits preserve a valid prefix and report truncation; a later source delta protocol can replace the transport without changing the snapshot model or CDP projection. The object table intentionally keeps every object in the current visible tree strongly reachable until the next replacement or observer disposal. This is bounded by the retained snapshot and must not become a general-purpose object registry. -The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory and may force a full Elements document refresh when an older disconnected tree is evicted. +The Worker retains only serialized metadata for a disconnected snapshot; any still-running source owns its realm-local object registry independently and disposal releases that registry. `maxDisconnectedCordisTrees` bounds Worker snapshot memory, and eviction removes the corresponding retained Client subtree. diff --git a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md index b08b8fcb8d..05b9b0d438 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-cordis-runtime-tree-inspection.zh.md @@ -39,7 +39,7 @@ collector 从 root、注册表中的每个 live Fiber,以及每个 event hook - Fiber `uid` 来自 Cordis。Context 当前没有 Cordis 自有 id,Inspector 不会暴露一个生成值来替代。 - `InspectorObjectReference` 是 realm 本地的不透明 handle,用于把树节点解析成实时 Context 或 Fiber。snapshot 携带该 handle 只为完成路由,不把它当成语义 id 或 DOM attribute。 - `BackendNodeId` 由 Worker 为一条保留的 `(source id, source generation, object reference)` 分配,并在该 generation 的 snapshot 被保留期间由所有 DevTools 连接共享。 -- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;`DOM.documentUpdated` 或连接关闭时丢弃。 +- `NodeId` 在节点进入某个 frontend document 时按 DevTools 连接分配;对应 backend node 被保留期间保持稳定,并在节点离开树、少见的整 document fallback 或连接关闭时丢弃。 - `RemoteObjectId` 在 `DOM.resolveNode` 暴露实时对象时由选定的 Runtime session 分配;它只属于该 DevTools 连接和 object group。 `sourceId` 标识一个 Client runtime instance,并在自动重连 transport 时保持稳定;`generation` 标识一次 WebSocket 接纳。断联通过 `Runtime.executionContextDestroyed` 从 Console 移除 synthetic context。重连会发布新的 CDP execution-context id,因为已销毁的 id 及其 RemoteObject 不能复用;这并不表示浏览器底层 JavaScript realm 被重新创建。 @@ -62,7 +62,7 @@ source 把 Cordis 树作为保留状态发布,而不是事件历史。Host Mes source 关闭时,存储的树从 connected 变为 disconnected,而不是删除最后一份 snapshot。对象查询会排除 disconnected 树,因此 snapshot 仍可作为数据检查,但不会保留或复活实时 Context、Fiber 或 Runtime object。同一 source id 的新 transport generation 提交 replacement 后,会原子恢复 connected 状态。可配置的 disconnected tree 数量上限会淘汰最早保留的 snapshot。 -接受 tree replacement 后发送 `DOM.documentUpdated`,要求 frontend 重新拉取 document。如果断联没有淘汰另一棵树,则只失效对象路由,不改变 DOM document,从而保留已加载的树、展开状态与选择。连接状态留在 inspection model 中,等待其 Elements 展示方式被明确设计。保留上限触发淘汰时回退到 `DOM.documentUpdated`。以后可以在 `CordisDomSession` 内增加更多增量 DOM diff,而无需修改 collector、snapshot 或模型消费方。 +每个被接受的 source snapshot 都会重建 connection-neutral document,并按稳定的 backend node identity 比较差异。只改变 revision 的 replacement 不发送 DOM event;子节点增删使用 `DOM.childNodeInserted` 与 `DOM.childNodeRemoved`,attribute 变化使用对应 DOM event,兄弟节点重排只对该 parent 使用 `DOM.setChildNodes`。只有同一 backend identity 被复用为不同 node kind 时才回退到 `DOM.documentUpdated`。断联只会使 object route 失效,不改变保留的 DOM tree,因此保留展开与选择;达到保留上限时只移除被淘汰的 `` 节点。 ## CDP projection @@ -97,7 +97,7 @@ synthetic document 包含一个 `` container 和一个 `` contain - `DOM.resolveNode` 与 `DOM.requestNode` 能往返映射 Context/Fiber 身份,且不会跨 DevTools 连接或 source generation 共享 object id。 - Runtime evaluation 返回的 Context 或 Fiber 会被标记为 node,并能在 Elements 中定位。 - 断联会销毁 Client execution context 与 RemoteObject,同时原样保留最后一棵 Elements 树;新的 transport generation 在完整 snapshot 到达后替换它。 -- 重连和 resnapshot 会重放最新树状态;畸形或超限 replacement 不会替换最后一个有效快照。 +- 重连和 resnapshot 会重放最新树状态;无变化的 snapshot 不发送 DOM mutation,结构变化只更新受影响的 parent 或 node。畸形或超限 replacement 不会替换最后一个有效快照。 - 存储的 snapshot 与查询 API 不包含 CDP 类型,可以不加修改地支持未来的模型适配器。 ## Consequences @@ -106,8 +106,8 @@ Cordis 不提供完整的全局 Context registry。collector 能恢复从 live f 需要语义识别的每个 Host object 都会增加一次 Runtime round trip。annotation 失败时保留普通 RemoteObject,不破坏 Runtime 或 Debugger 投递。Client Console observation 保留原始 method result,并在之后调度序列化;每个已启用 DevTools session 独立保留 handle,因此识别既不阻塞页面调用,也不在连接间共享对象。 -完整树 replacement 比增量 source mutation 更简单,但在超大运行时中可能昂贵。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换为 delta 协议,而不修改 snapshot model。 +source 仍发布完整 snapshot,从而复用同一套 Host/Client collector,并能在 observation 丢失后恢复。Worker 承担 snapshot 比较成本,再发送增量 CDP DOM mutation,使无变化的 revision 不会重置 Elements document。节点数与字节数限制会保留有效前缀并报告截断;以后可以替换 source delta 协议,而不修改 snapshot model 或 CDP projection。 对象表会有意强引用当前可见树中的每个对象,直到下一次 replacement 或 observer dispose。该集合受保留快照限制,不能扩展成通用对象注册表。 -Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰较早的断联树时,Elements document 可能需要完整刷新。 +Worker 对断联 snapshot 只保留序列化 metadata;仍在运行的 source 独立拥有其 realm-local object registry,dispose 会释放该 registry。`maxDisconnectedCordisTrees` 约束 Worker snapshot 内存;淘汰时会移除对应的已保留 Client subtree。 diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 69720dbcc8..822f4178c0 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md -README.md: 09f8b2901e8a8d5c6e8264d2e1be680d3b968178 -README.zh.md: 510ca42362a3c7e779a672b721ec9bc43ede07b5 +README.md: 9e955ae2ff14c7d6331c4081e373183cc768c212 +README.zh.md: dc2e8228bedad4262793109204fd4743b55be744 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 09f8b2901e..9e955ae2ff 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -102,6 +102,8 @@ The Elements document has fixed `` and `` containers. `` co Host and Client publish the same nested `CordisTreeSnapshot` type. Context and Fiber nodes carry opaque object handles for realm-local object lookup; Fiber nodes additionally carry Cordis `uid`. The Worker composes those realm snapshots into one `{ host, clients }` inspection tree. It assigns `BackendNodeId` values per source generation; each DevTools connection assigns its own `NodeId` values; `DOM.resolveNode` asks the owning Host or Client Runtime for a connection-local `RemoteObjectId`. `DOM.requestNode` maps that object id back to the same Elements node. `ctx.inspector.cordis.getTree()` and `DSHInspector.getCordisTree` read the detached consumer-neutral tree without routing handles or CDP ids. +Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable. + When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately. diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index 510ca42362..dc2e8228be 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -102,6 +102,8 @@ Elements document 包含固定的 `` 与 `` 容器。`` 包 Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Fiber 节点携带用于 realm-local 对象查询的不透明 object handle;Fiber 还携带 Cordis `uid`。Worker 把这些 realm snapshot 组合成一棵 `{ host, clients }` inspection tree。Worker 按 source generation 分配 `BackendNodeId`;每条 DevTools 连接分配自己的 `NodeId`;`DOM.resolveNode` 请求所属 Host 或 Client Runtime 生成连接本地 `RemoteObjectId`。`DOM.requestNode` 把该 object id 映射回同一个 Elements 节点。`ctx.inspector.cordis.getTree()` 与 `DSHInspector.getCordisTree` 读取不含 routing handle 或 CDP id 的 detached consumer-neutral tree。 +source 仍发布完整 snapshot,Worker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。 + Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。 diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts index d7288f784a..c153b4f3cc 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/index.ts @@ -1,4 +1,4 @@ /** Cordis semantic DOM domain exports. */ -export { CordisDomBackend, type CordisDomChange } from './model.ts' +export { CordisDomBackend, type CordisDomChange, type CordisDomMutation } from './model.ts' export { CordisDomSession } from './session.ts' diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts index ad70430dab..2401392a98 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/model.ts @@ -9,7 +9,6 @@ import type { CordisTreeObjectRoute, CordisTreeSourceSnapshot, CordisTreeStore, - CordisTreeStoreEvent, } from '../../../inspection/cordis-store.ts' /** One Worker-global backend node independent of any DevTools connection. */ @@ -31,9 +30,40 @@ export interface CordisDomDocument { readonly parentByBackendId: ReadonlyMap } -/** A full tree replacement or an in-place source availability change. */ -export type CordisDomChange = +/** One structural or attribute mutation between two projected documents. */ +export type CordisDomMutation = | { readonly type: 'document-updated' } + | { + readonly type: 'child-inserted' + readonly parentBackendNodeId: CdpBackendNodeId + readonly previousBackendNodeId: CdpBackendNodeId | 0 + readonly node: CordisDomNode + } + | { + readonly type: 'child-removed' + readonly parentBackendNodeId: CdpBackendNodeId + readonly node: CordisDomNode + } + | { + readonly type: 'children-replaced' + readonly parentBackendNodeId: CdpBackendNodeId + readonly children: readonly CordisDomNode[] + } + | { + readonly type: 'attribute-modified' + readonly backendNodeId: CdpBackendNodeId + readonly name: string + readonly value: string + } + | { + readonly type: 'attribute-removed' + readonly backendNodeId: CdpBackendNodeId + readonly name: string + } + +/** A visible incremental mutation or an in-place source availability change. */ +export type CordisDomChange = + | { readonly type: 'tree-mutated'; readonly mutations: readonly CordisDomMutation[] } | { readonly type: 'source-disconnected'; readonly source: InspectorSourceDescriptor } /** Assigns durable backend ids and projects the latest source snapshots. */ @@ -51,14 +81,9 @@ export class CordisDomBackend { this.unsubscribe = trees.subscribe((event) => { const previous = this.documentValue this.documentValue = this.build() - const change = this.change(event, previous) - for (const listener of [...this.listeners]) { - try { - listener(change) - } catch { - // One closed CDP connection cannot prevent sibling sessions from receiving the new document. - } - } + if (event.type === 'source-disconnected') this.emit({ type: 'source-disconnected', source: event.source }) + const mutations = diffDocument(previous, this.documentValue) + if (mutations.length > 0) this.emit({ type: 'tree-mutated', mutations }) }) } @@ -183,11 +208,14 @@ export class CordisDomBackend { return { backendNodeId, key, name, attributes, description, ...(object === undefined ? {} : { object }), children: [] } } - private change(event: CordisTreeStoreEvent, previous: CordisDomDocument): CordisDomChange { - if (event.type === 'source-disconnected' && sameNodeSet(previous, this.documentValue)) { - return { type: 'source-disconnected', source: event.source } + private emit(change: CordisDomChange): void { + for (const listener of [...this.listeners]) { + try { + listener(change) + } catch { + // One closed CDP connection cannot prevent sibling sessions from receiving the document mutation. + } } - return { type: 'document-updated' } } } @@ -204,10 +232,66 @@ function objectKey(source: InspectorSourceDescriptor, reference: InspectorObject return `${source.sourceId}\0${source.generation}\0${reference.registryId}\0${reference.handle}` } -function sameNodeSet(left: CordisDomDocument, right: CordisDomDocument): boolean { - if (left.byBackendId.size !== right.byBackendId.size) return false - for (const backendNodeId of left.byBackendId.keys()) { - if (!right.byBackendId.has(backendNodeId)) return false +function diffDocument(previous: CordisDomDocument, current: CordisDomDocument): CordisDomMutation[] { + const mutations: CordisDomMutation[] = [] + return diffNode(previous.root, current.root, mutations) + ? mutations + : [{ type: 'document-updated' }] +} + +function diffNode(previous: CordisDomNode, current: CordisDomNode, mutations: CordisDomMutation[]): boolean { + if (previous.backendNodeId !== current.backendNodeId || previous.name !== current.name) { + return false + } + const previousAttributes = new Map(previous.attributes) + const currentAttributes = new Map(current.attributes) + for (const [name, value] of currentAttributes) { + if (previousAttributes.get(name) === value) continue + mutations.push({ type: 'attribute-modified', backendNodeId: current.backendNodeId, name, value }) + } + for (const [name] of previousAttributes) { + if (!currentAttributes.has(name)) { + mutations.push({ type: 'attribute-removed', backendNodeId: current.backendNodeId, name }) + } + } + + const previousIds = previous.children.map(child => child.backendNodeId) + const currentIds = current.children.map(child => child.backendNodeId) + const previousSet = new Set(previousIds) + const currentSet = new Set(currentIds) + const retainedBefore = previousIds.filter(id => currentSet.has(id)) + const retainedAfter = currentIds.filter(id => previousSet.has(id)) + if (!sameIds(retainedBefore, retainedAfter)) { + mutations.push({ + type: 'children-replaced', + parentBackendNodeId: current.backendNodeId, + children: current.children, + }) + return true + } + for (const child of previous.children) { + if (!currentSet.has(child.backendNodeId)) { + mutations.push({ type: 'child-removed', parentBackendNodeId: current.backendNodeId, node: child }) + } + } + for (let index = 0; index < current.children.length; index++) { + const child = current.children[index] as CordisDomNode + if (previousSet.has(child.backendNodeId)) continue + mutations.push({ + type: 'child-inserted', + parentBackendNodeId: current.backendNodeId, + previousBackendNodeId: index === 0 ? 0 : (current.children[index - 1] as CordisDomNode).backendNodeId, + node: child, + }) + } + const previousById = new Map(previous.children.map(child => [child.backendNodeId, child])) + for (const child of current.children) { + const prior = previousById.get(child.backendNodeId) + if (prior !== undefined && !diffNode(prior, child, mutations)) return false } return true } + +function sameIds(left: readonly CdpBackendNodeId[], right: readonly CdpBackendNodeId[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts index 4f2e53181c..4b4aa97295 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts @@ -7,7 +7,7 @@ import { respondToCdpRequest, type CdpRequest, type CdpTransport } from '../../p import type { InspectorRealmDescriptor } from '../../../inspection/realm.ts' import type { RuntimeDomainSession } from '../runtime/index.ts' import type { RuntimeObjectPresentation } from '../runtime/object-table.ts' -import type { CordisDomBackend, CordisDomChange, CordisDomNode } from './model.ts' +import type { CordisDomBackend, CordisDomChange, CordisDomMutation, CordisDomNode } from './model.ts' import { cdpNumericId, cdpStringId, @@ -292,8 +292,97 @@ export class CordisDomSession { this.releaseSourceObjects(event.source) return } - this.resetDocument() - if (this.enabled) this.transport.send({ method: 'DOM.documentUpdated', params: {} }) + if (this.enabled) for (const mutation of event.mutations) this.sendMutation(mutation) + this.pruneDocumentState() + } + + private sendMutation(mutation: CordisDomMutation): void { + switch (mutation.type) { + case 'document-updated': + this.resetDocument() + this.transport.send({ method: 'DOM.documentUpdated', params: {} }) + return + case 'child-inserted': { + const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) + if (parentNodeId === undefined) return + const previousNodeId = mutation.previousBackendNodeId === 0 + ? 0 + : this.nodeIdByBackend.get(mutation.previousBackendNodeId) + if (previousNodeId === undefined) return + this.transport.send({ + method: 'DOM.childNodeInserted', + params: { + parentNodeId, + previousNodeId, + node: this.serialize(mutation.node, parentNodeId, true), + }, + }) + return + } + case 'child-removed': { + const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) + const nodeId = this.nodeIdByBackend.get(mutation.node.backendNodeId) + if (parentNodeId === undefined || nodeId === undefined) return + this.transport.send({ method: 'DOM.childNodeRemoved', params: { parentNodeId, nodeId } }) + return + } + case 'children-replaced': { + const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) + if (parentNodeId === undefined) return + this.transport.send({ + method: 'DOM.setChildNodes', + params: { + parentId: parentNodeId, + nodes: mutation.children.map(child => this.serialize(child, parentNodeId, true)), + }, + }) + return + } + case 'attribute-modified': { + const nodeId = this.nodeIdByBackend.get(mutation.backendNodeId) + if (nodeId !== undefined) { + this.transport.send({ + method: 'DOM.attributeModified', + params: { nodeId, name: mutation.name, value: mutation.value }, + }) + } + return + } + case 'attribute-removed': { + const nodeId = this.nodeIdByBackend.get(mutation.backendNodeId) + if (nodeId !== undefined) { + this.transport.send({ method: 'DOM.attributeRemoved', params: { nodeId, name: mutation.name } }) + } + return + } + default: + return assertNever(mutation) + } + } + + private pruneDocumentState(): void { + const document = this.backend.document() + for (const [backendNodeId, nodeId] of this.nodeIdByBackend) { + if (document.byBackendId.has(backendNodeId)) continue + this.nodeIdByBackend.delete(backendNodeId) + this.backendByNodeId.delete(nodeId) + } + for (const [objectId, binding] of this.backendByObjectId) { + const node = document.byBackendId.get(binding.backendNodeId) + const source = node?.object?.source + if (source?.sourceId === binding.sourceId && source.generation === binding.generation) continue + this.backendByObjectId.delete(objectId) + for (const [group, objectIds] of this.objectIdsByGroup) { + objectIds.delete(objectId) + if (objectIds.size === 0) this.objectIdsByGroup.delete(group) + } + } + for (const [searchId, nodeIds] of this.searches) { + this.searches.set(searchId, nodeIds.filter((nodeId) => { + const backendNodeId = this.backendByNodeId.get(nodeId) + return backendNodeId !== undefined && document.byBackendId.has(backendNodeId) + })) + } } private releaseSourceObjects(source: InspectorSourceDescriptor): void { @@ -359,3 +448,7 @@ function presentation(node: CordisDomNode): RuntimeObjectPresentation { description: node.description, } } + +function assertNever(value: never): never { + throw new Error(`Unexpected Cordis DOM mutation: ${JSON.stringify(value)}`) +} diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 22ceac488b..90741211f0 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -13,6 +13,7 @@ import type { InspectorJsonValue } from '../src/shared/json.ts' import { jsonByteLength } from '../src/shared/json.ts' import type { InspectorSourceDescriptor } from '../src/shared/bridge/messages/observation.ts' import { CordisTreeStore } from '../src/worker/inspection/cordis-store.ts' +import { CordisDomBackend, type CordisDomChange } from '../src/worker/cdp/domains/dom/model.ts' import { InspectorClientFixture } from './fixtures/client-source.host.ts' interface CdpMessage { @@ -277,6 +278,72 @@ describe('Cordis tree inspection', () => { collector.close() }) + it('diffs snapshots into local DOM mutations and suppresses revision-only updates', () => { + const store = new CordisTreeStore({ maxNodes: 100, maxDisconnectedTrees: 1 }) + const backend = new CordisDomBackend(store) + const changes: CordisDomChange[] = [] + backend.subscribe((event) => { changes.push(event) }) + const host = { ...source('host', 'generation-1'), kind: 'host' as const } + const context = (objectHandle: string, children: unknown[] = []): Record => ({ + kind: 'context', + objectHandle, + children, + }) + const fiber = (uid: number, objectHandle: string): Record => ({ + kind: 'fiber', + uid, + objectHandle, + children: [context(`${objectHandle}-context`)], + }) + const snapshot = (revision: number, children: unknown[]): InspectorJsonValue => ({ + schemaVersion: 0, + revision, + objectRegistryId: 'registry', + root: context('root', children), + truncated: false, + }) as InspectorJsonValue + const replace = (revision: number, children: unknown[]): void => { + store.append(host, [{ sequence: revision, monotonicMs: revision, topic: 'cordis/tree', payload: snapshot(revision, children) }]) + } + + replace(1, [fiber(1, 'fiber-1')]) + expect(changes.at(-1)).toMatchObject({ type: 'tree-mutated', mutations: [{ type: 'child-inserted' }] }) + changes.length = 0 + replace(2, [fiber(1, 'fiber-1')]) + expect(changes).toEqual([]) + + replace(3, [fiber(2, 'fiber-1')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'attribute-modified', name: 'uid', value: '2' })] }), + ]) + changes.length = 0 + + replace(4, [fiber(2, 'fiber-1'), context('context-2')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'child-inserted' })] }), + ]) + changes.length = 0 + replace(5, [fiber(2, 'fiber-1')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'child-removed' })] }), + ]) + + changes.length = 0 + replace(6, [context('context-a'), context('context-b')]) + changes.length = 0 + replace(7, [context('context-b'), context('context-a')]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [expect.objectContaining({ type: 'children-replaced' })] }), + ]) + + changes.length = 0 + replace(8, [{ kind: 'fiber', uid: 3, objectHandle: 'context-a', children: [context('changed-kind')] }]) + expect(changes).toEqual([ + expect.objectContaining({ type: 'tree-mutated', mutations: [{ type: 'document-updated' }] }), + ]) + backend.close() + }) + it('projects Host and Client trees and resolves both node kinds to RemoteObjects', async () => { inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) const host = new Context() @@ -455,6 +522,59 @@ describe('Cordis tree inspection', () => { expect(disconnectedTree.clients[0]?.connection.state).toBe('disconnected') }) + it('emits only node-level DOM changes for Client snapshots', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + const initialDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode + const clientsNode = initialDocument.children?.find(node => node.localName === 'clients') + if (clientsNode === undefined) throw new Error('DOM document has no clients container') + + let offset = cdp.events.length + clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Incremental Client' }) + await vi.waitFor(() => { + const events = cdp!.events.slice(offset) + const inserted = events.find(event => event.method === 'DOM.childNodeInserted') + expect(inserted?.params?.parentNodeId).toBe(clientsNode.nodeId) + expect(inserted?.params?.node).toMatchObject({ localName: 'client' }) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + + const firstTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ revision: number }> + } + const firstRevision = firstTree.clients[0]?.revision + offset = cdp.events.length + await clientSource.refreshTree() + await vi.waitFor(async () => { + const tree = (await cdp!.call('DSHInspector.getCordisTree')).result?.tree as { + clients: Array<{ revision: number }> + } + expect(tree.clients[0]?.revision).toBeGreaterThan(firstRevision ?? 0) + }) + expect(cdp.events.slice(offset).some(event => event.method?.startsWith('DOM.'))).toBe(false) + + offset = cdp.events.length + const uid = await clientSource.addFiber() + let insertedNodeId: number | undefined + await vi.waitFor(() => { + const inserted = cdp!.events.slice(offset).find(event => event.method === 'DOM.childNodeInserted' + && (event.params?.node as CdpNode | undefined)?.localName === 'fiber' + && (event.params?.node as CdpNode | undefined)?.attributes?.includes(String(uid))) + insertedNodeId = (inserted?.params?.node as CdpNode | undefined)?.nodeId + expect(insertedNodeId).toBeTypeOf('number') + expect(cdp!.events.slice(offset).some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + + offset = cdp.events.length + await clientSource.removeFiber() + await vi.waitFor(() => { + const events = cdp!.events.slice(offset) + const removed = events.find(event => event.method === 'DOM.childNodeRemoved') + expect(removed?.params?.nodeId).toBe(insertedNodeId) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + }) + }) + it('restores a disconnected Client tree from a new transport generation', async () => { inspector = await startInspector({ port: 0, @@ -493,11 +613,14 @@ describe('Cordis tree inspection', () => { const context = event.params?.context as { id?: number } | undefined return typeof context?.id === 'number' && context.id !== contextId }) - const refreshed = events.findIndex(event => event.method === 'DOM.documentUpdated') + const removed = events.findIndex(event => event.method === 'DOM.childNodeRemoved') + const inserted = events.findIndex(event => event.method === 'DOM.childNodeInserted') expect(destroyed).toBeGreaterThanOrEqual(0) expect(created).toBeGreaterThan(destroyed) - expect(refreshed).toBeGreaterThan(created) + expect(removed).toBeGreaterThan(created) + expect(inserted).toBeGreaterThan(removed) expect(events.slice(0, created).some(event => event.method?.startsWith('DOM.'))).toBe(false) + expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) }) await vi.waitFor(async () => { diff --git a/packages/experimental/inspector/tests/fixtures/client-source.client.ts b/packages/experimental/inspector/tests/fixtures/client-source.client.ts index a6616facda..367b7ebb61 100644 --- a/packages/experimental/inspector/tests/fixtures/client-source.client.ts +++ b/packages/experimental/inspector/tests/fixtures/client-source.client.ts @@ -1,7 +1,7 @@ /** Client-face process fixture used by Host-side protocol integration tests. */ import { parentPort, workerData } from 'node:worker_threads' -import { Context } from '@deepseek-ai/cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import WebSocket from 'ws' import { ClientInspectorSource } from '../../src/client/bridge/transport.ts' import { ClientSourceCatalog } from '../../src/client/cdp/sources.ts' @@ -24,7 +24,17 @@ interface ClientFixtureInput { interface ClientFixtureRequest { readonly id: number - readonly op: 'close' | 'disconnect' | 'get-tree' | 'log-cordis' | 'log-value' | 'publish' | 'set-global' + readonly op: + | 'add-fiber' + | 'close' + | 'disconnect' + | 'get-tree' + | 'log-cordis' + | 'log-value' + | 'publish' + | 'refresh-tree' + | 'remove-fiber' + | 'set-global' readonly name?: string readonly value?: InspectorJsonValue readonly marker?: string @@ -60,6 +70,7 @@ const disposeCordis = publishCordisTree(context, source, { maxBytes: input.bootstrap.maxFrameBytes - 4_096, }) const service = createInspectorService(source) +let addedFiber: Fiber | undefined port.on('message', (message: ClientFixtureRequest) => { void dispatch(message).then( @@ -100,7 +111,19 @@ async function dispatch(message: ClientFixtureRequest): Promise { socket?.terminate() return undefined } + case 'refresh-tree': + context.emit('internal/status', childFiber.ctx.fiber, childFiber.ctx.fiber.state) + return undefined + case 'add-fiber': + addedFiber = context.plugin({ name: 'dynamic-client-child', apply() {} }).ctx.fiber + await addedFiber.await() + return addedFiber.uid + case 'remove-fiber': + await addedFiber?.dispose() + addedFiber = undefined + return undefined case 'close': + await addedFiber?.dispose() disposeCordis() source.close() await context.fiber.dispose() diff --git a/packages/experimental/inspector/tests/fixtures/client-source.host.ts b/packages/experimental/inspector/tests/fixtures/client-source.host.ts index 8b817c49c4..aaf69d6ce6 100644 --- a/packages/experimental/inspector/tests/fixtures/client-source.host.ts +++ b/packages/experimental/inspector/tests/fixtures/client-source.host.ts @@ -103,6 +103,21 @@ export class InspectorClientFixture { await this.request({ op: 'disconnect' }) } + /** Trigger a Cordis observation without changing the runtime tree. */ + async refreshTree(): Promise { + await this.request({ op: 'refresh-tree' }) + } + + /** Add one Fiber to the inspected Client runtime. */ + async addFiber(): Promise { + return await this.request({ op: 'add-fiber' }) as number + } + + /** Remove the Fiber most recently added by {@link addFiber}. */ + async removeFiber(): Promise { + await this.request({ op: 'remove-fiber' }) + } + /** Dispose the Client source and its Cordis context. */ async close(): Promise { if (this.closed) return From 777489dfc5c1f36398d921e28d90b199e396e686 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:20:03 +0800 Subject: [PATCH 076/130] fix(inspector): print the startup URL --- packages/experimental/inspector/src/host/plugin.ts | 3 ++- packages/experimental/inspector/tests/plugin.host.spec.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/experimental/inspector/src/host/plugin.ts b/packages/experimental/inspector/src/host/plugin.ts index 0e54f3425e..a03a1dcc1a 100644 --- a/packages/experimental/inspector/src/host/plugin.ts +++ b/packages/experimental/inspector/src/host/plugin.ts @@ -49,7 +49,8 @@ export async function apply(ctx: Context, config: HostPluginConfig): Promise { table.push({ kind: 'global', name: '__DSH_INSPECTOR__', value: handle.endpoint.client }) })) - ctx.logger.info(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`) + // This readiness URL is emitted while the plugin tree is still loading, before a logger sink is guaranteed. + console.log(`dsh inspector: ${handle.endpoint.devtoolsFrontendUrl}`) } catch (error) { await disposeInspector(handle, disposers).catch((cleanupError: unknown) => { ctx.logger.error('experimental-inspector: initialization rollback failed', cleanupError) diff --git a/packages/experimental/inspector/tests/plugin.host.spec.ts b/packages/experimental/inspector/tests/plugin.host.spec.ts index f6a28ea2a2..905c655d96 100644 --- a/packages/experimental/inspector/tests/plugin.host.spec.ts +++ b/packages/experimental/inspector/tests/plugin.host.spec.ts @@ -23,7 +23,7 @@ describe('experimental Inspector Host plugin', () => { it('starts the Worker, provides ctx.inspector, injects Client bootstrap, and disposes', async () => { context = new Context() - const log = vi.spyOn(context.logger, 'info').mockImplementation(() => undefined) + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) context.provide('webServer', {} as WebServer) const fiber = context.plugin( { name, inject: [...inject], Config, apply }, From a031b95fdb4ad13218865261ab9364927b4dc064 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 11:22:58 +0800 Subject: [PATCH 077/130] fix(inspector): preserve responses after caller abort --- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 2 +- packages/experimental/inspector/README.zh.md | 2 +- .../inspector/src/host/inspection/network.ts | 8 ---- .../tests/fetch-observer.host.spec.ts | 13 +++--- .../inspector/tests/integration.host.spec.ts | 45 +++++++++++++++++++ .../inspector/tests/network.host.spec.ts | 25 ++++++----- 7 files changed, 71 insertions(+), 28 deletions(-) diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 822f4178c0..09fb248050 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md -README.md: 9e955ae2ff14c7d6331c4081e373183cc768c212 -README.zh.md: dc2e8228bedad4262793109204fd4743b55be744 +README.md: ff1aa86ee5920b1641d1a0b13e04b95b5691d036 +README.zh.md: 213086441ac7280b22c4c0ffba7b48b863f69fb9 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index 9e955ae2ff..ff1aa86ee5 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -113,7 +113,7 @@ Fetch capture is on by default and records the complete URL, all request and res The configured body limits bound retention rather than select fields: capture keeps the prefix and marks the result truncated. `Network.getRequestPostData` and `Network.getResponseBody` read the Worker's retained bytes. `Network.streamResourceContent` returns the buffered prefix and adds later response bytes to `Network.dataReceived` for that DevTools connection, which drives live Response and EventStream views. Direct Undici Client/Dispatcher calls and fetch references retained before plugin activation are outside this observer. -After response headers arrive, bytes captured before a caller-side abort remain available through `Network.getResponseBody`, while the request ends with `Network.loadingFailed { canceled: true }`. A fetch rejection before response headers follows the same canceled failure path. +After response headers arrive, a caller-side abort can stop the observer's clone; captured bytes remain available through `Network.getResponseBody`, capture metadata records the error and truncation, and CDP emits `Network.loadingFinished` because fetch returned a Response. A fetch rejection before response headers emits `Network.loadingFailed`, with `canceled: true` for an abort. ## Security diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index dc2e8228be..213086441a 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -113,7 +113,7 @@ fetch 采集默认开启,记录完整 URL、全部请求与响应 headers、 配置的 body 上限限制保留量,而不选择字段:采集保留前缀并标记 truncated。`Network.getRequestPostData` 与 `Network.getResponseBody` 读取 Worker 保留的字节。`Network.streamResourceContent` 返回已缓冲的前缀,并仅为发起调用的 DevTools 连接把后续 response 字节附加到 `Network.dataReceived`,以驱动实时 Response 与 EventStream 视图。直接调用 Undici Client/Dispatcher,以及插件激活前保存的 fetch 引用,不在观察范围内。 -response headers 到达后,如果调用方 abort,已采集的字节仍可通过 `Network.getResponseBody` 读取,同时请求以 `Network.loadingFailed { canceled: true }` 结束。response headers 到达前发生的 fetch rejection 也走相同的取消失败路径。 +response headers 到达后,调用方 abort 可能会终止 observer clone;已采集的字节仍可通过 `Network.getResponseBody` 读取,采集 metadata 记录错误与截断,并且 CDP 因 fetch 已返回 Response 而发送 `Network.loadingFinished`。response headers 到达前发生的 fetch rejection 会发送 `Network.loadingFailed`,其中 abort 对应 `canceled: true`。 ## 安全 diff --git a/packages/experimental/inspector/src/host/inspection/network.ts b/packages/experimental/inspector/src/host/inspection/network.ts index 8ff9af0477..aa5225a8c5 100644 --- a/packages/experimental/inspector/src/host/inspection/network.ts +++ b/packages/experimental/inspector/src/host/inspection/network.ts @@ -120,14 +120,6 @@ export function installFetchObserver( controller.signal, (data) => { publisher.publish('fetch/response-body-chunk', { requestId, data }) }, ).then((outcome) => { - if (request.signal.aborted && outcome.captureError !== undefined) { - publisher.publish('fetch/error', { - requestId, - message: outcome.captureError, - canceled: true, - }) - return - } publisher.publish('fetch/end', { requestId, capturedBytes: outcome.capturedBytes, diff --git a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts index 69652b869d..a79551a75d 100644 --- a/packages/experimental/inspector/tests/fetch-observer.host.spec.ts +++ b/packages/experimental/inspector/tests/fetch-observer.host.spec.ts @@ -80,7 +80,7 @@ describe('full fetch observer', () => { expect(payload(records, 'fetch/end')).toMatchObject({ capturedBytes: 4, responseBodyTruncated: true }) }) - it('retains captured bytes and reports cancellation after response headers', async () => { + it('finishes response capture when the caller aborts after response headers', async () => { const records: InspectorRecordInput[] = [] Object.defineProperty(globalThis, 'fetch', { value: vi.fn(async (request: Request) => new Response(new ReadableStream({ @@ -104,14 +104,15 @@ describe('full fetch observer', () => { const response = await fetch('https://example.test/cancel-body', { signal: abort.signal }) abort.abort() await expect(response.text()).rejects.toThrow() - await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/error')).toBe(true) }) + await vi.waitFor(() => { expect(records.some(record => record.topic === 'fetch/end')).toBe(true) }) expect(decodeChunks(records, 'fetch/response-body-chunk')).toBe('first') - expect(payload(records, 'fetch/error')).toMatchObject({ - message: 'AbortError: aborted', - canceled: true, + expect(payload(records, 'fetch/end')).toMatchObject({ + capturedBytes: 5, + responseBodyTruncated: true, + responseCaptureError: 'AbortError: aborted', }) - expect(records.some(record => record.topic === 'fetch/end')).toBe(false) + expect(records.some(record => record.topic === 'fetch/error')).toBe(false) }) it('reports a fetch rejected before response headers as a canceled request', async () => { diff --git a/packages/experimental/inspector/tests/integration.host.spec.ts b/packages/experimental/inspector/tests/integration.host.spec.ts index 5b214c7fd5..b50ea4aced 100644 --- a/packages/experimental/inspector/tests/integration.host.spec.ts +++ b/packages/experimental/inspector/tests/integration.host.spec.ts @@ -574,6 +574,51 @@ describe('experimental Inspector real Worker', () => { continueResponse.resolve(true) } }) + + it('keeps captured EventSource data readable when the caller aborts after response headers', async () => { + const eventStream = 'data: first\n\ndata: [DONE]\n\n' + server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8' }) + response.write(eventStream) + }) + await new Promise((resolve) => { server!.listen(0, '127.0.0.1', () => { resolve() }) }) + const port = (server.address() as import('node:net').AddressInfo).port + inspector = await startInspector({ port: 0 }) + cdp = await TestCdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await cdp.call('Network.enable') + const abort = new AbortController() + + const response = await fetch(`http://127.0.0.1:${String(port)}/aborted-events`, { signal: abort.signal }) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('SSE response did not expose a body') + expect(Buffer.from((await reader.read()).value ?? []).toString('utf8')).toBe(eventStream) + + let requestId: string | undefined + await vi.waitFor(() => { + const received = cdp!.events.find(event => + event.method === 'Network.responseReceived' + && String((event.params?.response as Record | undefined)?.url).includes('/aborted-events')) + requestId = received?.params?.requestId as string | undefined + expect(requestId).toBeTypeOf('string') + expect(cdp!.events.filter(event => + event.method === 'Network.eventSourceMessageReceived' + && event.params?.requestId === requestId).map(event => event.params?.data)).toEqual(['first', '[DONE]']) + }) + abort.abort() + + await vi.waitFor(() => { + expect(cdp!.events.some(event => + event.method === 'Network.loadingFinished' + && event.params?.requestId === requestId)).toBe(true) + }) + expect(cdp.events.some(event => + event.method === 'Network.loadingFailed' + && event.params?.requestId === requestId)).toBe(false) + const body = await cdp.call('Network.getResponseBody', { requestId }) + expect(Buffer.from(String(body.result?.body), 'base64').toString('utf8')).toBe(eventStream) + expect(body.result?.dshInspectorTruncated).toBe(true) + expect(String(body.result?.dshInspectorCaptureError)).toContain('AbortError') + }) }) async function clientContext(client: TestCdpClient): Promise { diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts index 69b34b440d..6ad8c3e846 100644 --- a/packages/experimental/inspector/tests/network.host.spec.ts +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -170,29 +170,34 @@ describe('Inspector Network domain', () => { expect(replay).toHaveBeenNthCalledWith(3, 'Network.loadingFinished', expect.any(Object)) }) - it('retains partial response bytes while reporting a post-header cancellation as failed', () => { + it('finishes a response whose observer clone ended with a capture error', () => { const sendEvent = vi.fn() const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) const network = new NetworkDomain(store) network.enable({ sendEvent }) - const records = requestRecords('canceled', 'partial') + const records = requestRecords('capture-error', 'partial') store.append(source, [ ...records.slice(0, 3), { sequence: 4, monotonicMs: 4, - topic: 'fetch/error', - payload: { requestId: 'canceled', message: 'AbortError: aborted', canceled: true }, + topic: 'fetch/end', + payload: { + requestId: 'capture-error', + capturedBytes: 7, + responseBodyTruncated: true, + responseCaptureError: 'AbortError: aborted', + }, }, ]) - expect(sendEvent).toHaveBeenCalledWith('Network.loadingFailed', expect.objectContaining({ - requestId: requestId('canceled'), - type: 'Fetch', - errorText: 'AbortError: aborted', - canceled: true, + expect(sendEvent).toHaveBeenCalledWith('Network.loadingFinished', expect.objectContaining({ + requestId: requestId('capture-error'), + encodedDataLength: 7, + dshInspectorTruncated: true, })) - expect(network.handle('Network.getResponseBody', { requestId: requestId('canceled') }, { sendEvent: vi.fn() })) + expect(sendEvent.mock.calls.some(call => call[0] === 'Network.loadingFailed')).toBe(false) + expect(network.handle('Network.getResponseBody', { requestId: requestId('capture-error') }, { sendEvent: vi.fn() })) .toMatchObject({ body: Buffer.from('partial').toString('base64'), dshInspectorTruncated: true, From ef712e3006087f71e33ab07a635cd4e54cd7ebc1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:49:55 +0800 Subject: [PATCH 078/130] fix(inspector): serve Cordis DOM levels on demand --- .../experimental/inspector/README.i18n.yaml | 4 +- packages/experimental/inspector/README.md | 4 +- packages/experimental/inspector/README.zh.md | 4 +- .../src/worker/cdp/domains/dom/session.ts | 90 +++++++++++++++--- .../inspector/tests/cordis-tree.host.spec.ts | 94 ++++++++++++++++++- 5 files changed, 178 insertions(+), 18 deletions(-) diff --git a/packages/experimental/inspector/README.i18n.yaml b/packages/experimental/inspector/README.i18n.yaml index 09fb248050..5353fad454 100644 --- a/packages/experimental/inspector/README.i18n.yaml +++ b/packages/experimental/inspector/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/experimental/inspector/README.md -README.md: ff1aa86ee5920b1641d1a0b13e04b95b5691d036 -README.zh.md: 213086441ac7280b22c4c0ffba7b48b863f69fb9 +README.md: e10a68eed10c0bc71d26b5eacf9ee3eac4c6818d +README.zh.md: f6aceb5374730ff9879151980c60e54dad39fd89 diff --git a/packages/experimental/inspector/README.md b/packages/experimental/inspector/README.md index ff1aa86ee5..e10a68eed1 100644 --- a/packages/experimental/inspector/README.md +++ b/packages/experimental/inspector/README.md @@ -102,7 +102,9 @@ The Elements document has fixed `` and `` containers. `` co Host and Client publish the same nested `CordisTreeSnapshot` type. Context and Fiber nodes carry opaque object handles for realm-local object lookup; Fiber nodes additionally carry Cordis `uid`. The Worker composes those realm snapshots into one `{ host, clients }` inspection tree. It assigns `BackendNodeId` values per source generation; each DevTools connection assigns its own `NodeId` values; `DOM.resolveNode` asks the owning Host or Client Runtime for a connection-local `RemoteObjectId`. `DOM.requestNode` maps that object id back to the same Elements node. `ctx.inspector.cordis.getTree()` and `DSHInspector.getCordisTree` read the detached consumer-neutral tree without routing handles or CDP ids. -Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable. +Node delivery is depth-limited per DevTools connection: `DOM.getDocument` serves three document levels when the caller omits `depth`, withheld levels advertise `childNodeCount`, and expansion fetches them through `DOM.requestChildNodes` (`depth: -1` for a whole subtree). NodeIds leaving through `DOM.performSearch`, `DOM.requestNode`, or `DOM.pushNodesByBackendIdsToFrontend` first push the not-yet-sent ancestor levels as `DOM.setChildNodes` events. + +Sources publish complete snapshots, while the Worker compares stable backend node identities before notifying DevTools. Unchanged snapshots emit no DOM event; additions, removals, and attribute changes use node-level CDP events, inserted-node payloads withhold their subtree, and sibling reordering replaces only that parent's children. Existing `NodeId` values and unaffected Elements expansion remain stable. When a Client disconnects, its Console execution context and live object ids are destroyed immediately. With disconnected-tree retention enabled, Elements keeps the last tree unchanged while connection state remains in the inspection model rather than becoming an unreviewed DOM attribute. Reconnection keeps the logical source id, creates a new synthetic CDP context id for the new transport generation, and replaces the stale tree after its complete snapshot arrives. The Worker retains at most `maxDisconnectedCordisTrees` such snapshots; zero removes them immediately. diff --git a/packages/experimental/inspector/README.zh.md b/packages/experimental/inspector/README.zh.md index 213086441a..f6aceb5374 100644 --- a/packages/experimental/inspector/README.zh.md +++ b/packages/experimental/inspector/README.zh.md @@ -102,7 +102,9 @@ Elements document 包含固定的 `` 与 `` 容器。`` 包 Host 与 Client 发布同一种嵌套 `CordisTreeSnapshot` 类型。Context 与 Fiber 节点携带用于 realm-local 对象查询的不透明 object handle;Fiber 还携带 Cordis `uid`。Worker 把这些 realm snapshot 组合成一棵 `{ host, clients }` inspection tree。Worker 按 source generation 分配 `BackendNodeId`;每条 DevTools 连接分配自己的 `NodeId`;`DOM.resolveNode` 请求所属 Host 或 Client Runtime 生成连接本地 `RemoteObjectId`。`DOM.requestNode` 把该 object id 映射回同一个 Elements 节点。`ctx.inspector.cordis.getTree()` 与 `DSHInspector.getCordisTree` 读取不含 routing handle 或 CDP id 的 detached consumer-neutral tree。 -source 仍发布完整 snapshot,Worker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。 +节点按 DevTools 连接做深度受限下发:调用方省略 `depth` 时 `DOM.getDocument` 提供三层 document,被扣留的层级通过 `childNodeCount` 声明数量,展开时经 `DOM.requestChildNodes` 获取(`depth: -1` 取整棵子树)。经 `DOM.performSearch`、`DOM.requestNode` 或 `DOM.pushNodesByBackendIdsToFrontend` 流出的 NodeId 会先把尚未下发的祖先层级以 `DOM.setChildNodes` event 推送出去。 + +source 仍发布完整 snapshot,Worker 在通知 DevTools 前按稳定的 backend node identity 比较差异。无变化的 snapshot 不发送 DOM event;新增、移除和 attribute 变化使用节点级 CDP event,插入节点的载荷扣留其子树,兄弟节点重排只替换对应 parent 的 children。现有 `NodeId` 与未受影响的 Elements 展开状态保持稳定。 Client 断联时,其 Console execution context 与 live object id 会立即销毁。启用断联树保留后,Elements 会原样保留最后一棵树;连接状态留在 inspection model 中,不会未经设计就成为 DOM attribute。重连会沿用逻辑 source id,为新的 transport generation 创建新的 synthetic CDP context id,并在完整 snapshot 到达后替换旧树。Worker 最多保留 `maxDisconnectedCordisTrees` 棵此类 snapshot;设为零会立即移除。 diff --git a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts index 4b4aa97295..a6737ae958 100644 --- a/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts +++ b/packages/experimental/inspector/src/worker/cdp/domains/dom/session.ts @@ -21,16 +21,27 @@ const READ_ONLY_METHODS = new Set([ 'DOM.setOuterHTML', 'DOM.removeNode', 'DOM.moveTo', 'DOM.copyTo', ]) +/** + * Children levels `DOM.getDocument` serves when the caller omits `depth`; + * deeper levels arrive through `DOM.requestChildNodes` on expand. + */ +const DEFAULT_DOCUMENT_DEPTH = 3 + interface BoundDomObject { readonly backendNodeId: CdpBackendNodeId readonly sourceId: string readonly generation: string } -/** Connection-local NodeId, search, and RemoteObject mapping owner. */ +/** + * Connection-local NodeId, search, and RemoteObject mapping owner. Node payloads are depth-limited; + * withheld levels are fetched through `DOM.requestChildNodes` or pushed with the ancestor chain + * when a NodeId leaves through search or object lookup. + */ export class CordisDomSession { private readonly nodeIdByBackend = new Map() private readonly backendByNodeId = new Map() + private readonly childrenSent = new Set() private readonly backendByObjectId = new Map() private readonly objectIdsByGroup = new Map>() private readonly searches = new Map() @@ -118,21 +129,23 @@ export class CordisDomSession { return {} case 'DOM.getDocument': this.enabled = true - return { root: this.serialize(this.backend.document().root, 0, true) } + return { root: this.serialize(this.backend.document().root, 0, depthParam(params.depth, DEFAULT_DOCUMENT_DEPTH), true) } case 'DOM.requestChildNodes': { const node = this.fromNodeId(params.nodeId) + const depth = depthParam(params.depth, 1) + this.childrenSent.add(node.backendNodeId) this.transport.send({ method: 'DOM.setChildNodes', params: { parentId: numberParam(params.nodeId, 'nodeId'), - nodes: node.children.map(child => this.serialize(child, this.nodeId(node), true)), + nodes: node.children.map(child => this.serialize(child, this.nodeId(node), depth - 1, true)), }, }) return {} } case 'DOM.describeNode': { const node = this.selectNode(params) - return { node: this.serialize(node, this.parentNodeId(node), true) } + return { node: this.serialize(node, this.parentNodeId(node), depthParam(params.depth, 1), false) } } case 'DOM.getAttributes': return { attributes: this.fromNodeId(params.nodeId).attributes.flat() } @@ -144,7 +157,9 @@ export class CordisDomSession { nodeIds: params.backendNodeIds.map((value) => { if (!Number.isSafeInteger(value) || (value as number) < 1) return 0 const node = this.backend.document().byBackendId.get(cdpBackendNodeId(value, 'backendNodeId')) - return node === undefined ? 0 : this.nodeId(node) + if (node === undefined) return 0 + this.pushNodePath(node) + return this.nodeId(node) }), } } @@ -156,6 +171,7 @@ export class CordisDomSession { if (binding === undefined) throw new Error('RemoteObject is not a current Cordis node') const node = this.backend.document().byBackendId.get(binding.backendNodeId) if (node === undefined) throw new Error('Cordis node is no longer available') + this.pushNodePath(node) return { nodeId: this.nodeId(node) } } case 'DOM.performSearch': { @@ -169,9 +185,13 @@ export class CordisDomSession { } case 'DOM.getSearchResults': { const ids = this.searches.get(stringParam(params.searchId, 'searchId')) ?? [] - return { - nodeIds: ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex')), + const nodeIds = ids.slice(nonNegativeInteger(params.fromIndex, 'fromIndex'), nonNegativeInteger(params.toIndex, 'toIndex')) + for (const nodeId of nodeIds) { + const backendId = this.backendByNodeId.get(nodeId) + const node = backendId === undefined ? undefined : this.backend.document().byBackendId.get(backendId) + if (node !== undefined) this.pushNodePath(node) } + return { nodeIds } } case 'DOM.discardSearchResults': this.searches.delete(stringParam(params.searchId, 'searchId')) @@ -244,9 +264,13 @@ export class CordisDomSession { return node } - private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, children: boolean): object { + private serialize(node: CordisDomNode, parentId: CdpNodeId | 0, remaining: number, delivery: boolean): object { const nodeId = this.nodeId(node) const document = node.name === '#document' + const withChildren = remaining > 0 + // `DOM.describeNode` results are out-of-band descriptions the frontend does not merge into its tree, + // so only delivery payloads record which nodes already carried their children. + if (delivery && withChildren) this.childrenSent.add(node.backendNodeId) return { nodeId, backendNodeId: node.backendNodeId, @@ -257,11 +281,38 @@ export class CordisDomSession { ...(parentId === 0 ? {} : { parentId }), ...(document ? { documentURL: 'dsh://cordis', baseURL: 'dsh://cordis' } : {}), childNodeCount: node.children.length, - ...(children ? { children: node.children.map(child => this.serialize(child, nodeId, true)) } : {}), + ...(withChildren ? { children: node.children.map(child => this.serialize(child, nodeId, remaining - 1, delivery)) } : {}), attributes: node.attributes.flat(), } } + /** Deliver the not-yet-sent ancestor levels of one node so its NodeId attaches to the frontend tree. */ + private pushNodePath(node: CordisDomNode): void { + const document = this.backend.document() + const chain: CordisDomNode[] = [] + let backendId = document.parentByBackendId.get(node.backendNodeId) + while (backendId !== undefined) { + const parent = document.byBackendId.get(backendId) + if (parent === undefined) break + chain.unshift(parent) + backendId = document.parentByBackendId.get(parent.backendNodeId) + } + for (const ancestor of chain) { + if (this.childrenSent.has(ancestor.backendNodeId)) continue + const parentId = this.nodeId(ancestor) + this.childrenSent.add(ancestor.backendNodeId) + this.transport.send({ + method: 'DOM.setChildNodes', + params: { parentId, nodes: ancestor.children.map(child => this.serialize(child, parentId, 0, true)) }, + }) + } + } + + private forgetSubtree(node: CordisDomNode): void { + this.childrenSent.delete(node.backendNodeId) + for (const child of node.children) this.forgetSubtree(child) + } + private nodeId(node: CordisDomNode): CdpNodeId { let nodeId = this.nodeIdByBackend.get(node.backendNodeId) if (nodeId === undefined) { @@ -285,6 +336,7 @@ export class CordisDomSession { this.backendByObjectId.clear() this.objectIdsByGroup.clear() this.searches.clear() + this.childrenSent.clear() } private updateDocument(event: CordisDomChange): void { @@ -309,12 +361,14 @@ export class CordisDomSession { ? 0 : this.nodeIdByBackend.get(mutation.previousBackendNodeId) if (previousNodeId === undefined) return + // A reconnected source reuses backend ids; the collapsed payload resets any earlier delivery record. + this.forgetSubtree(mutation.node) this.transport.send({ method: 'DOM.childNodeInserted', params: { parentNodeId, previousNodeId, - node: this.serialize(mutation.node, parentNodeId, true), + node: this.serialize(mutation.node, parentNodeId, 0, true), }, }) return @@ -322,6 +376,7 @@ export class CordisDomSession { case 'child-removed': { const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) const nodeId = this.nodeIdByBackend.get(mutation.node.backendNodeId) + this.forgetSubtree(mutation.node) if (parentNodeId === undefined || nodeId === undefined) return this.transport.send({ method: 'DOM.childNodeRemoved', params: { parentNodeId, nodeId } }) return @@ -329,11 +384,14 @@ export class CordisDomSession { case 'children-replaced': { const parentNodeId = this.nodeIdByBackend.get(mutation.parentBackendNodeId) if (parentNodeId === undefined) return + // Replacement payloads carry no grandchildren, so the frontend forgets any it knew below this parent. + for (const child of mutation.children) this.forgetSubtree(child) + this.childrenSent.add(mutation.parentBackendNodeId) this.transport.send({ method: 'DOM.setChildNodes', params: { parentId: parentNodeId, - nodes: mutation.children.map(child => this.serialize(child, parentNodeId, true)), + nodes: mutation.children.map(child => this.serialize(child, parentNodeId, 0, true)), }, }) return @@ -367,6 +425,9 @@ export class CordisDomSession { this.nodeIdByBackend.delete(backendNodeId) this.backendByNodeId.delete(nodeId) } + for (const backendNodeId of this.childrenSent) { + if (!document.byBackendId.has(backendNodeId)) this.childrenSent.delete(backendNodeId) + } for (const [objectId, binding] of this.backendByObjectId) { const node = document.byBackendId.get(binding.backendNodeId) const source = node?.object?.source @@ -417,6 +478,13 @@ function numberParam(value: unknown, name: string): number { return value as number } +function depthParam(value: unknown, fallback: number): number { + if (value === undefined) return fallback + if (value === -1) return Number.POSITIVE_INFINITY + if (!Number.isSafeInteger(value) || (value as number) < 1) throw new Error('depth must be -1 or a positive integer') + return value as number +} + function cdpNodeId(value: unknown, name: string): CdpNodeId { if (!Number.isSafeInteger(value)) throw new Error(`${name} must be an integer`) return cdpNumericId<'CdpNodeId'>(value as number, name) diff --git a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts index 90741211f0..586e720245 100644 --- a/packages/experimental/inspector/tests/cordis-tree.host.spec.ts +++ b/packages/experimental/inspector/tests/cordis-tree.host.spec.ts @@ -29,6 +29,7 @@ interface CdpNode { readonly backendNodeId: number readonly localName: string readonly attributes?: string[] + readonly childNodeCount?: number readonly children?: CdpNode[] } @@ -359,7 +360,7 @@ describe('Cordis tree inspection', () => { let document: CdpNode | undefined await vi.waitFor(async () => { - const response = await cdp!.call('DOM.getDocument') + const response = await cdp!.call('DOM.getDocument', { depth: -1 }) expect(response.error).toBeUndefined() document = response.result?.root as CdpNode expect(hostContainer(document)).toBeDefined() @@ -488,7 +489,7 @@ describe('Cordis tree inspection', () => { const firstResolved = await cdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId }) const firstObjectId = (firstResolved.result?.object as Record).objectId secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) - const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode + const secondDocument = (await secondCdp.call('DOM.getDocument', { depth: -1 })).result?.root as CdpNode const secondNode = walk(secondDocument).find(node => node.backendNodeId === clientNode.backendNodeId) expect(secondNode).toBeDefined() const secondResolved = await secondCdp.call('DOM.resolveNode', { backendNodeId: clientNode.backendNodeId }) @@ -506,7 +507,7 @@ describe('Cordis tree inspection', () => { expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) }) - const disconnectedDocument = (await cdp.call('DOM.getDocument')).result?.root as CdpNode + const disconnectedDocument = (await cdp.call('DOM.getDocument', { depth: -1 })).result?.root as CdpNode const disconnectedClient = clientContainers(disconnectedDocument)[0] expect(disconnectedClient).toBeDefined() expect(walk(disconnectedClient!).find(node => node.backendNodeId === clientNode.backendNodeId)?.nodeId) @@ -531,13 +532,18 @@ describe('Cordis tree inspection', () => { let offset = cdp.events.length clientSource = await InspectorClientFixture.start(inspector.endpoint.client, { label: 'Incremental Client' }) + let insertedClient: CdpNode | undefined await vi.waitFor(() => { const events = cdp!.events.slice(offset) const inserted = events.find(event => event.method === 'DOM.childNodeInserted') expect(inserted?.params?.parentNodeId).toBe(clientsNode.nodeId) expect(inserted?.params?.node).toMatchObject({ localName: 'client' }) expect(events.some(event => event.method === 'DOM.documentUpdated')).toBe(false) + insertedClient = inserted?.params?.node as CdpNode }) + // The collapsed insert payload withholds the realm subtree; expand it to follow deeper changes. + expect(insertedClient?.children).toBeUndefined() + await cdp.call('DOM.requestChildNodes', { nodeId: insertedClient!.nodeId, depth: -1 }) const firstTree = (await cdp.call('DSHInspector.getCordisTree')).result?.tree as { clients: Array<{ revision: number }> @@ -575,6 +581,88 @@ describe('Cordis tree inspection', () => { }) }) + it('serves three document levels by default and withheld levels on demand', async () => { + inspector = await startInspector({ port: 0, captureFetch: false, maxCordisNodes: 100 }) + const host = new Context() + let innerFiber: { uid: number | null } | undefined + const outer = host.plugin({ + name: 'outer', + apply(ctx: Context) { innerFiber = ctx.plugin({ name: 'inner', apply() {} }) }, + }) + fibers.push(outer) + await outer.await() + const innerUid = innerFiber?.uid + if (innerFiber === undefined || innerUid === null || innerUid === undefined) { + throw new Error('nested plugin did not register a uid') + } + observers.push(publishHostCordisTree(host, inspector.source, { maxNodes: 100, maxBytes: 64 * 1_024 })) + cdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + + // Default document depth ends at the first Fiber layer: children withheld, count advertised. + let outerNode: CdpNode | undefined + await vi.waitFor(async () => { + const document = (await cdp!.call('DOM.getDocument')).result?.root as CdpNode + outerNode = hostContainer(document)?.children?.[0]?.children + ?.find(node => node.localName === 'fiber' && node.attributes?.includes(String(outer.uid))) + expect(outerNode).toBeDefined() + }) + expect(outerNode?.children).toBeUndefined() + expect(outerNode?.childNodeCount).toBe(1) + + // Expanding serves exactly one more level by default. + let offset = cdp.events.length + await cdp.call('DOM.requestChildNodes', { nodeId: outerNode!.nodeId }) + const expanded = cdp.events.slice(offset).find(event => event.method === 'DOM.setChildNodes') + expect(expanded?.params?.parentId).toBe(outerNode!.nodeId) + const outerContext = (expanded?.params?.nodes as CdpNode[])[0] + expect(outerContext).toMatchObject({ localName: 'context', childNodeCount: 1 }) + expect(outerContext?.children).toBeUndefined() + + // Expand-recursively requests the entire subtree. + offset = cdp.events.length + await cdp.call('DOM.requestChildNodes', { nodeId: outerNode!.nodeId, depth: -1 }) + const recursive = cdp.events.slice(offset).find(event => event.method === 'DOM.setChildNodes') + const recursiveContext = (recursive?.params?.nodes as CdpNode[])[0] + expect(recursiveContext?.children?.[0]).toMatchObject({ + localName: 'fiber', + attributes: ['uid', String(innerUid)], + }) + expect((await cdp.call('DOM.getDocument', { depth: 0 })).error?.message).toContain('depth') + + // A NodeId leaving through search or object lookup pushes the not-yet-sent ancestor levels first. + secondCdp = await CdpClient.connect(inspector.endpoint.webSocketDebuggerUrl) + await secondCdp.call('Runtime.enable') + const secondDocument = (await secondCdp.call('DOM.getDocument')).result?.root as CdpNode + const secondOuter = walk(secondDocument).find(node => node.attributes?.includes(String(outer.uid))) + const described = (await secondCdp.call('DOM.describeNode', { nodeId: secondOuter?.nodeId })).result?.node as CdpNode + expect(described.children?.[0]?.localName).toBe('context') + expect(described.children?.[0]?.children).toBeUndefined() + + const search = await secondCdp.call('DOM.performSearch', { query: `uid=${JSON.stringify(String(innerUid))}` }) + expect(search.result?.resultCount).toBe(1) + offset = secondCdp.events.length + const results = await secondCdp.call('DOM.getSearchResults', { + searchId: search.result?.searchId, + fromIndex: 0, + toIndex: 1, + }) + const innerNodeId = (results.result?.nodeIds as number[])[0] + const pushed = secondCdp.events.slice(offset).filter(event => event.method === 'DOM.setChildNodes') + expect(pushed).toHaveLength(2) + await expect(secondCdp.call('DOM.getAttributes', { nodeId: innerNodeId })).resolves.toMatchObject({ + result: { attributes: ['uid', String(innerUid)] }, + }) + + Reflect.set(globalThis, '__cordisHostProbe', innerFiber) + const evaluated = await secondCdp.call('Runtime.evaluate', { expression: 'globalThis.__cordisHostProbe' }) + expect(evaluated.result?.result).toMatchObject({ subtype: 'node', className: 'Fiber' }) + offset = secondCdp.events.length + await expect(secondCdp.call('DOM.requestNode', { + objectId: (evaluated.result?.result as Record).objectId, + })).resolves.toMatchObject({ result: { nodeId: innerNodeId } }) + expect(secondCdp.events.slice(offset).some(event => event.method === 'DOM.setChildNodes')).toBe(false) + }) + it('restores a disconnected Client tree from a new transport generation', async () => { inspector = await startInspector({ port: 0, From 15572ddb22578e95ea76f9fceb3aac4a4f7e3e15 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:07:38 +0800 Subject: [PATCH 079/130] feat(inspector): add the development mount overlay and demo script --- ...8-27-inspector-development-mount.i18n.yaml | 6 ++++ .../2026-08-27-inspector-development-mount.md | 30 +++++++++++++++++++ ...26-08-27-inspector-development-mount.zh.md | 30 +++++++++++++++++++ package.json | 1 + .../experimental/inspector/cordis.patch.yml | 13 ++++++++ 5 files changed, 80 insertions(+) create mode 100644 .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md create mode 100644 .agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md create mode 100644 packages/experimental/inspector/cordis.patch.yml diff --git a/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.i18n.yaml new file mode 100644 index 0000000000..1403e3a7e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.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-27-inspector-development-mount.md +2026-08-27-inspector-development-mount.md: 0e5f0af52306ebb553e4fb911696cfc3fae087ee +2026-08-27-inspector-development-mount.zh.md: 252e52692df2ad983e6da9ee9640c5ae6603f20f diff --git a/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md new file mode 100644 index 0000000000..0e5f0af523 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.md @@ -0,0 +1,30 @@ +# Agent Note: Inspector development mount + +Status: implemented + +English | [中文](2026-08-27-inspector-development-mount.zh.md) + +## Problem + +`@deepseek-ai/dsh-experimental-inspector` is a private package no published dsh installation carries, yet development launches need to mount it into the shipped Web composition on demand. A row in a shipped bundle patch cannot express this: `verify-cordis-config` requires every named row of a bundle patch to resolve from that bundle's own `dependencies` — disabled rows included — and a published manifest must not depend on an unpublished package. + +## Decision + +The inspector package owns a development overlay, `packages/experimental/inspector/cordis.patch.yml`, holding a single `insert` of the `experimental-inspector` row. A launch selects it through the generic overlay flag; `pnpm run demo:inspector` is the shorthand for `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml`. + +The overlay contributes only the row; the row's module resolves from the profile plane at entry import: + +- A source launch (`pnpm dsh`, tsx) resolves the workspace package through the tsconfig `paths` facade and needs no installation. +- A built launch (`node apps/cli/lib/bin.js`) needs the package importable from the profile first: `dsh plugin --profile web add link:`, once per profile. `link:` keeps dependency resolution inside the real package directory; `file:` re-installs the package's `workspace:^` dependencies in the profile and fails with `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND`. + +A launch whose profile cannot import the package fails loud at entry import (`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from `); nothing is skipped silently. + +## Consequences + +Published packages carry no trace of the inspector: no manifest entry, no composition row, no launcher flag. Mounting stays a per-launch choice — the same service without the overlay never loads the package — and every layer the launch composes is declared in a config file. The cost is launch-mode asymmetry: a built launch needs the one-time profile `link:` install, and the overlay must be named on every invocation, which `pnpm run demo:inspector` absorbs for the common case. + +## Alternatives considered + +- A `disabled: !!js` row in the shipped web-app patch: the dependency gate and npm publication both force the private package into the published manifest. +- A `--inspector` launcher flag mounting the package as an extra bundle layer: the launcher owns neither app flags nor plugin package names. +- An optional `peerDependencies` entry on `dsh-web-app` plus a dynamic `ctx.loader.create` from its glue plugin: it writes a never-published name into a published manifest and mounts a row no config layer declares. diff --git a/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md new file mode 100644 index 0000000000..252e52692d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-inspector-development-mount.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Inspector 开发挂载 + +Status: implemented + +[English](2026-08-27-inspector-development-mount.md) | 中文 + +## Problem + +`@deepseek-ai/dsh-experimental-inspector` 是任何已发布 dsh 安装都不携带的 private 包,但开发启动需要按需把它挂进随货 Web 组合。随货 bundle patch 里的一行表达不了这件事:`verify-cordis-config` 要求 bundle patch 中每个具名行都能从该 bundle 自己的 `dependencies` 解析——disabled 行也不豁免——而已发布的 manifest 不得依赖未发布的包。 + +## Decision + +inspector 包自有一份开发 overlay,`packages/experimental/inspector/cordis.patch.yml`,只含一个 `insert` 的 `experimental-inspector` 行。启动通过通用 overlay flag 选它;`pnpm run demo:inspector` 是 `pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml` 的简写。 + +overlay 只贡献这一行;行的模块在 entry import 时从 profile 平面解析: + +- 源码启动(`pnpm dsh`,tsx)经 tsconfig `paths` 门面解析 workspace 包,无需任何安装。 +- built 启动(`node apps/cli/lib/bin.js`)需先让包可从 profile import:`dsh plugin --profile web add link:<包目录绝对路径>`,每个 profile 一次。`link:` 让依赖解析留在真实包目录内;`file:` 会在 profile 里重装该包的 `workspace:^` 依赖并以 `ERR_PNPM_WORKSPACE_PKG_NOT_FOUND` 失败。 + +profile 无法 import 该包的启动会在 entry import 处响亮失败(`Cannot find package '@deepseek-ai/dsh-experimental-inspector' imported from `);不存在静默跳过。 + +## Consequences + +已发布的包不携带 inspector 的任何痕迹:没有 manifest 条目、没有组合行、没有 launcher flag。挂载保持按次启动选择——不带 overlay 的同一服务永远不会加载该包——且启动组合的每一层都由 config 文件声明。代价是启动方式不对称:built 启动需要一次性 profile `link:` 安装,且每次调用都要点名 overlay,常见场景由 `pnpm run demo:inspector` 吸收。 + +## Alternatives considered + +- 随货 web-app patch 里放 `disabled: !!js` 行:依赖门禁与 npm 发布都会把 private 包逼进已发布 manifest。 +- `--inspector` launcher flag 把包挂成额外 bundle 层:launcher 既不拥有 app flag 也不拥有插件包名。 +- `dsh-web-app` 上加 optional `peerDependencies` 并由其 glue 插件动态 `ctx.loader.create`:向已发布 manifest 写入永不发布的名字,且挂载的行不在任何 config 层声明。 diff --git a/package.json b/package.json index bec66ae4c9..c9394768b2 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", + "demo:inspector": "pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/experimental/inspector/cordis.patch.yml b/packages/experimental/inspector/cordis.patch.yml new file mode 100644 index 0000000000..56da3b95df --- /dev/null +++ b/packages/experimental/inspector/cordis.patch.yml @@ -0,0 +1,13 @@ +# Development overlay for the experimental inspector: mount it per launch with +# pnpm run demo:inspector (pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml) +# A source launch resolves this workspace package through the tsconfig paths +# facade and needs no installation. A built launch additionally needs the +# package importable from the profile: +# dsh plugin --profile web add link: +# (`link:`, not `file:` — `file:` re-installs the workspace:^ dependencies +# inside the profile and fails). The package is private and ships with no +# published dsh installation; a missing package fails loud at entry import. + +- insert: + - id: experimental-inspector + name: '@deepseek-ai/dsh-experimental-inspector' From 1c1c0adf6ea1f16c3ba30c95261e6306fc78134e Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:23:21 +0800 Subject: [PATCH 080/130] fix(inspector): classify the demo launcher and refresh the module graph --- docs/module-graph.i18n.yaml | 2 +- docs/module-graph.md | 43 +++++++++++++++-------- package.json | 2 +- scripts/verify-application-entrypoints.ts | 1 + 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 1fda2fbe2d..d7cdef3d7a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 0708aca1672546d28e956109f0fd7e5c255f4613 +module-graph.md: c54901d6951908eaef728b16d295c1f749e7fe22 module-graph.zh.md: ee0a7e54bef93247c1056d8736868c3de8e813d3 diff --git a/docs/module-graph.md b/docs/module-graph.md index 0708aca167..c54901d695 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -108,6 +108,7 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] + pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] @@ -411,8 +412,6 @@ flowchart TD pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants - pkg_settings --> pkg_brand - pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -442,10 +441,6 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants - pkg_settings_file --> pkg_atomic_write - pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants - pkg_settings_file --> pkg_settings pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -508,6 +503,9 @@ flowchart TD pkg_session_persistence --> pkg_timeout pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants + pkg_settings --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_llm_retry --> pkg_agent @@ -541,6 +539,11 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_file_reference --> pkg_typert_protocol @@ -609,6 +612,10 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_settings_file --> pkg_atomic_write + pkg_settings_file --> pkg_home_paths + pkg_settings_file --> pkg_invariants + pkg_settings_file --> pkg_settings pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings @@ -1092,6 +1099,7 @@ flowchart TD pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands @@ -1107,7 +1115,10 @@ flowchart TD pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title pkg_session_reference --> pkg_typert_protocol pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_instructions @@ -1296,6 +1307,7 @@ flowchart TD pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner @@ -1539,12 +1551,15 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_api_session_controller + pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol + pkg_client_ui_reference --> pkg_util_workspace_path pkg_client_ui_subagent --> pkg_api_session_controller pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale @@ -1620,7 +1635,6 @@ flowchart TD pkg_client_ui_model_selection --> pkg_typert_protocol pkg_client_ui_permission_presets --> pkg_api_remotes pkg_client_ui_permission_presets --> pkg_api_session_controller - pkg_client_ui_permission_presets --> pkg_client_connection pkg_client_ui_permission_presets --> pkg_client_locale pkg_client_ui_permission_presets --> pkg_client_ui_commands pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger @@ -1732,7 +1746,6 @@ flowchart TD | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1743,7 +1756,6 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1763,6 +1775,7 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | @@ -1770,6 +1783,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | @@ -1785,6 +1799,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1863,9 +1878,9 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | @@ -1891,7 +1906,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1918,7 +1933,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1927,7 +1942,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/package.json b/package.json index c9394768b2..6d9e308f4e 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:inspector": "pnpm dsh web --patch ./packages/experimental/inspector/cordis.patch.yml", + "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index bbe67e5b80..964383a62f 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -49,6 +49,7 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ /** Root demos are application wrappers and therefore must visibly select dsh. */ const ROOT_DEMO_POLICIES = new Map([ ['demo:code-mode', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-code-mode.mjs' }], + ['demo:inspector', { kind: 'dsh-direct' }], ]) const SOURCE_PATTERNS = [ From 3f4a6a26984abe356e57cba91c8486159b1088aa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:28:26 +0800 Subject: [PATCH 081/130] docs: align the module graph translation pair --- docs/module-graph.i18n.yaml | 2 +- docs/module-graph.zh.md | 43 +++++++++++++++++++++++++------------ 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d7cdef3d7a..bcb0fe6b05 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md module-graph.md: c54901d6951908eaef728b16d295c1f749e7fe22 -module-graph.zh.md: ee0a7e54bef93247c1056d8736868c3de8e813d3 +module-graph.zh.md: 67e8c8227035f0bbe411c557aba7ce3fe7b60e03 diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index ee0a7e54be..67e8c82270 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -110,6 +110,7 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] + pkg_api_settings_controller["api-settings-controller"] pkg_api_workspace_controller["api-workspace-controller"] end subgraph group_attachment["packages/attachment"] @@ -413,8 +414,6 @@ flowchart TD pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants - pkg_settings --> pkg_brand - pkg_settings --> pkg_invariants pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants @@ -444,10 +443,6 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants - pkg_settings_file --> pkg_atomic_write - pkg_settings_file --> pkg_home_paths - pkg_settings_file --> pkg_invariants - pkg_settings_file --> pkg_settings pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -510,6 +505,9 @@ flowchart TD pkg_session_persistence --> pkg_timeout pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_settings --> pkg_brand + pkg_settings --> pkg_invariants + pkg_settings --> pkg_session pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_llm_retry --> pkg_agent @@ -543,6 +541,11 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants pkg_file_reference --> pkg_typert_protocol @@ -611,6 +614,10 @@ flowchart TD pkg_session_title --> pkg_llm pkg_session_title --> pkg_session pkg_session_title --> pkg_session_projection + pkg_settings_file --> pkg_atomic_write + pkg_settings_file --> pkg_home_paths + pkg_settings_file --> pkg_invariants + pkg_settings_file --> pkg_settings pkg_shell --> pkg_invariants pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings @@ -1094,6 +1101,7 @@ flowchart TD pkg_client_connection --> pkg_invariants pkg_client_connection --> pkg_llm pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings pkg_client_connection --> pkg_tool_todo pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands @@ -1109,7 +1117,10 @@ flowchart TD pkg_session_reference --> pkg_llm pkg_session_reference --> pkg_output_retention pkg_session_reference --> pkg_session + pkg_session_reference --> pkg_session_projection + pkg_session_reference --> pkg_session_projection_cache pkg_session_reference --> pkg_session_query + pkg_session_reference --> pkg_session_title pkg_session_reference --> pkg_typert_protocol pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_instructions @@ -1298,6 +1309,7 @@ flowchart TD pkg_api_remotes --> pkg_agent_presets pkg_api_remotes --> pkg_api_gateway pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller pkg_api_remotes --> pkg_api_workspace_controller pkg_api_remotes --> pkg_commands pkg_api_remotes --> pkg_cordis_host_runner @@ -1541,12 +1553,15 @@ flowchart TD pkg_client_ui_commands --> pkg_invariants pkg_client_ui_commands --> pkg_session pkg_client_ui_reference --> pkg_api_remotes + pkg_client_ui_reference --> pkg_api_session_controller + pkg_client_ui_reference --> pkg_client_connection pkg_client_ui_reference --> pkg_client_locale pkg_client_ui_reference --> pkg_client_ui_input_trigger pkg_client_ui_reference --> pkg_file_reference pkg_client_ui_reference --> pkg_invariants pkg_client_ui_reference --> pkg_session_reference pkg_client_ui_reference --> pkg_typert_protocol + pkg_client_ui_reference --> pkg_util_workspace_path pkg_client_ui_subagent --> pkg_api_session_controller pkg_client_ui_subagent --> pkg_client_connection pkg_client_ui_subagent --> pkg_client_locale @@ -1622,7 +1637,6 @@ flowchart TD pkg_client_ui_model_selection --> pkg_typert_protocol pkg_client_ui_permission_presets --> pkg_api_remotes pkg_client_ui_permission_presets --> pkg_api_session_controller - pkg_client_ui_permission_presets --> pkg_client_connection pkg_client_ui_permission_presets --> pkg_client_locale pkg_client_ui_permission_presets --> pkg_client_ui_commands pkg_client_ui_permission_presets --> pkg_client_ui_input_trigger @@ -1734,7 +1748,6 @@ flowchart TD | [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | @@ -1745,7 +1758,6 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1765,6 +1777,7 @@ flowchart TD | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings) | @@ -1772,6 +1785,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | @@ -1787,6 +1801,7 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`session-telemetry`](../packages/session/session-telemetry) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-title`](../packages/session/session-title) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1865,9 +1880,9 @@ flowchart TD | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | -| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`typert-protocol`](../packages/typert/protocol) | +| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | @@ -1893,7 +1908,7 @@ flowchart TD | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | | [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1920,7 +1935,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1929,7 +1944,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | +| [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | From 90cae21deb3b07d3a3b5ff66cf2706322c42e061 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:52:40 +0800 Subject: [PATCH 082/130] fix: ci --- .../shared/bridge/messages/runtime/frames.ts | 4 +++ .../inspector/tests/network.host.spec.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts index aa58dd1290..9d06c6e738 100644 --- a/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts +++ b/packages/experimental/inspector/src/shared/bridge/messages/runtime/frames.ts @@ -131,6 +131,9 @@ export function parseClientRuntimeCancelFrame(value: Record): C * @param value - Untrusted acknowledgement frame. * @returns The validated acknowledgement frame. */ +/* jscpd:ignore-start */ +// Deliberately mirrors parseClientRuntimeCancelFrame: each wire parser spells +// out its own envelope literally instead of sharing a tag-parameterized helper. export function parseClientRuntimeResponseAcknowledgedFrame( value: Record, ): ClientRuntimeResponseAcknowledgedFrame { @@ -147,6 +150,7 @@ export function parseClientRuntimeResponseAcknowledgedFrame( requestId: wireId<'ClientRuntimeRequestId'>(value.requestId, 'requestId'), } } +/* jscpd:ignore-end */ /** * Parse and rebuild one Client-to-Worker Runtime response. diff --git a/packages/experimental/inspector/tests/network.host.spec.ts b/packages/experimental/inspector/tests/network.host.spec.ts index 6ad8c3e846..09298cad7d 100644 --- a/packages/experimental/inspector/tests/network.host.spec.ts +++ b/packages/experimental/inspector/tests/network.host.spec.ts @@ -205,6 +205,31 @@ describe('Inspector Network domain', () => { }) }) + it('marks a failure after response headers truncated with the transport error', () => { + const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) + const observed: unknown[] = [] + const unsubscribe = store.subscribe((event) => { observed.push(event) }) + store.append(source, [ + ...requestRecords('midstream', 'partial').slice(0, 3), + { + sequence: 4, + monotonicMs: 4, + topic: 'fetch/error', + payload: { requestId: 'midstream', message: 'socket reset', canceled: false }, + }, + ]) + + expect(store.responseBody(requestId('midstream'))).toMatchObject({ + bytes: Buffer.from('partial'), + truncated: true, + captureError: 'socket reset', + complete: true, + }) + expect(observed.at(-1)).toMatchObject({ type: 'request-failed', errorText: 'socket reset', canceled: false }) + unsubscribe() + store.dispose() + }) + it('retains request capture metadata and isolates malformed observations', () => { const store = new NetworkStore({ maxRetainedRequests: 10, maxJournalBytes: 1_024 }) const observed: unknown[] = [] From 1c808341ecdd7aad6058746cec19b238c92946f5 Mon Sep 17 00:00:00 2001 From: fz Date: Tue, 4 Aug 2026 11:15:29 +0800 Subject: [PATCH 083/130] Add global CJK/Latin auto-spacing via text-autospace Progressive enhancement on body in the shell base sheet so mixed Chinese/English copy gets consistent spacing without content edits; engines without support ignore the property. --- packages/client/web/README.i18n.yaml | 4 ++-- packages/client/web/README.md | 2 ++ packages/client/web/README.zh.md | 2 ++ packages/client/web/src/base.css | 12 ++++++++++++ .../web/tests/base-styles.client.spec.ts | 18 ++++++++++++++++++ 5 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/client/web/README.i18n.yaml b/packages/client/web/README.i18n.yaml index 6463fcad94..96037ccb77 100644 --- a/packages/client/web/README.i18n.yaml +++ b/packages/client/web/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/web/README.md -README.md: c83cc02fb776a233b8891d05ebd72a06b6223c8d -README.zh.md: 1dd46325017169a1663387870f057fb8ddfbe8d1 +README.md: c505698413b29aebe5c91d05311305c36bd7949a +README.zh.md: 176679338c0e824afe917b834293ceea0fb392a3 diff --git a/packages/client/web/README.md b/packages/client/web/README.md index c83cc02fb7..c505698413 100644 --- a/packages/client/web/README.md +++ b/packages/client/web/README.md @@ -27,6 +27,8 @@ English | [中文](README.zh.md) Use it when you assemble the browser application: `apps/web`'s Vite entry runs `new AppWebEntry(container).run()` against the mount point, and the boot page carries the user through activation. Ordinary browser callers pass no options. A pre-injected page transport is the default ahead of the `seams` override: when `globalThis.__DSH_TRANSPORT__` carries `loadBundle`, the module stage adopts it as the bundle transport and skips the immediate-tier HTTP prefetch, while explicit `seams` still win (for example jsdom tests, where external ` -` } diff --git a/packages/experimental/inspector/tests/plugin.client.spec.ts b/packages/experimental/inspector/tests/plugin.client.spec.ts index a9bb012bf7..6f18841eae 100644 --- a/packages/experimental/inspector/tests/plugin.client.spec.ts +++ b/packages/experimental/inspector/tests/plugin.client.spec.ts @@ -3,6 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply } from '../src/client/index.ts' +import { ClientRealmSource } from '../src/client/inspection/realm.ts' import type { InspectorClientBootstrap } from '../src/shared/bridge/messages/control.ts' class FakeWebSocket extends EventTarget { @@ -208,6 +209,48 @@ describe('experimental Inspector Client plugin', () => { await secondFiber.dispose() }) + it('rotates a copied session identity while its original page remains live', async () => { + const descriptor = Object.getOwnPropertyDescriptor(navigator, 'locks') + const held = new Set() + const request = async ( + name: string, + _options: LockOptions, + callback: (lock: Lock | null) => unknown, + ): Promise => { + const acquired = !held.has(name) + if (acquired) held.add(name) + try { + return await callback(acquired ? { name, mode: 'exclusive' } : null) + } finally { + if (acquired) held.delete(name) + } + } + Object.defineProperty(navigator, 'locks', { + configurable: true, + value: { request }, + }) + let first: ClientRealmSource | undefined + let duplicate: ClientRealmSource | undefined + let refreshed: ClientRealmSource | undefined + try { + first = await ClientRealmSource.claim('first') + duplicate = await ClientRealmSource.claim('duplicate') + expect(duplicate.sourceId).not.toBe(first.sourceId) + + first.close() + await vi.waitFor(() => { expect(held.size).toBe(1) }) + sessionStorage.setItem('dsh.experimental-inspector.client-source-id.v0', first.sourceId) + refreshed = await ClientRealmSource.claim('refreshed') + expect(refreshed.sourceId).toBe(first.sourceId) + } finally { + first?.close() + duplicate?.close() + refreshed?.close() + if (descriptor === undefined) Reflect.deleteProperty(navigator, 'locks') + else Object.defineProperty(navigator, 'locks', descriptor) + } + }) + it('falls back to a page-lifetime source id when session storage is unavailable', async () => { globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket globalThis.__DSH_INSPECTOR__ = bootstrap From 827acd07b100783caa6f2de87065c675478f8f8c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:10:20 +0800 Subject: [PATCH 091/130] docs(client-modules): align resolver contract --- docs/subsystems/client-modules.i18n.yaml | 4 +- docs/subsystems/client-modules.md | 4 +- docs/subsystems/client-modules.zh.md | 4 +- packages/client/modules/src/index.ts | 2 +- .../modules/tests/node-half.client.spec.ts | 51 +++++++++++++++++++ .../src/module-system/module-loader.ts | 1 + 6 files changed, 59 insertions(+), 7 deletions(-) diff --git a/docs/subsystems/client-modules.i18n.yaml b/docs/subsystems/client-modules.i18n.yaml index 56d92e2097..db0e53ba0e 100644 --- a/docs/subsystems/client-modules.i18n.yaml +++ b/docs/subsystems/client-modules.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/client-modules.md -client-modules.md: 6188880fcd682c7c3c105764f212e92395444b61 -client-modules.zh.md: a732758492e1dbc9cbaef8d3effbd3310e4f10a1 +client-modules.md: f58cb6592a009292ffc4f87a207fe4e103fd2365 +client-modules.zh.md: 18c72dfba6f851776100a9b6200e7222d580db7c diff --git a/docs/subsystems/client-modules.md b/docs/subsystems/client-modules.md index 6188880fcd..f58cb6592a 100644 --- a/docs/subsystems/client-modules.md +++ b/docs/subsystems/client-modules.md @@ -74,11 +74,11 @@ Each initial row's `rev` is an opaque process nonce plus sequence, so graph comp ## The scan -A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Package resolution anchors at the config tree's `ctx.baseUrl` — the cordis.yml directory, whose package declares every composed plugin as a dependency — and construction throws when that anchor is unset. +A package joins the table by declaring `dsh.client` (`platform: 'web'`, optional `inject` edges, optional `immediately`) in its package.json and exporting its built bundle at `exports["./client"]`. Each live row resolves from its own Loader specifier and owning-tree `baseUrl`, through the same `loader.internal.resolveSync` implementation that imports its Host face when available. The nearest owning package manifest supplies the browser module id, so relative source and built overlays retain the package identity. Distinct active Loader sources resolving to one package name fail composition; after one source unloads, the surviving source supplies the row without a fiber restart. Scanning is incremental per package; there is no full-rescan code path. Every cordis `internal/plugin` emission (fiber construction or disposal) marks the fiber's entry name dirty, and a microtask flush reconciles each dirty name against the live loader entries. The activation pass seeds the same dirty set with all current entries and flushes synchronously, so first scan and steady state share one implementation — with opposite failure postures. At activation, a malformed declaration or missing bundle among the already-loaded entries aggregates into one loud `AggregateError` listing every broken package: the fiber FAILS and the boot's fail-loud sweep reports it. In steady state, a broken package logs a warning and must not poison the others. -Package metadata — including the negative "not a client package" verdict — is cached per name and never expires: plugin-set changes take effect on restart. A fiber restart reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`. +Package metadata — including the negative "not a client package" verdict — is cached per Loader specifier and owning-tree base URL until restart. A fiber restart from the same source reuses its row and rev untouched; bundle content changes reach the graph only through `rebuilt()`. ## The bundle route and index injection diff --git a/docs/subsystems/client-modules.zh.md b/docs/subsystems/client-modules.zh.md index a732758492..18c72dfba6 100644 --- a/docs/subsystems/client-modules.zh.md +++ b/docs/subsystems/client-modules.zh.md @@ -74,11 +74,11 @@ interface WebBootGraph { ## 扫描 -包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。包解析锚定在配置树的 `ctx.baseUrl`——即 cordis.yml 所在目录,该目录的包把每个被组合的插件声明为依赖——这一锚点未设置时,构造即抛错。 +包加入这张表的方式,是在自己的 package.json 中声明 `dsh.client`(`platform: 'web'`、可选的 `inject` 边、可选的 `immediately`),并在 `exports["./client"]` 导出构建好的 bundle。每个 live row 都从自己的 Loader specifier 与所属 tree `baseUrl` 解析;若 `loader.internal.resolveSync` 可用,则使用 Host face import 所用的同一个实现。最近归属的 package manifest 提供浏览器模块 id,因此相对 source 与 built overlay 仍保留包身份。若不同的 active Loader source 解析到同一包名,组合会失败;一个来源卸载后,仍存活的来源无需重启 fiber 即可提供该 row。 扫描是单包增量的;不存在全量重扫代码路径。fiber 构造或 dispose(资源释放)时的每次 cordis `internal/plugin` 发射都把该 fiber 的 entry 名标脏,一次微任务 flush 把每个脏名与实时 loader entry 对账。激活趟以全部当前 entry 灌入同一个脏集合并同步 flush,因此初扫与稳态共享一条实现——但失败姿态相反。激活时,已加载 entry 中的畸形声明或缺失 bundle 会聚合为一个大声的 `AggregateError`,列出每个损坏的包:该 fiber 进入 FAILED,由启动的大声失败 sweep 上报。稳态下,损坏的包只记录一条警告,且不得殃及其他包。 -包元数据——包括「非 client 包」这一否定结论——按名缓存且永不过期:插件集合的变更在重启后生效。fiber 重启原样复用其行与 rev;bundle 内容变更只经 `rebuilt()` 到达图。 +包元数据——包括「非 client 包」这一否定结论——按 Loader specifier 与所属 tree base URL 缓存至重启。同一来源的 fiber 重启会原样复用其 row 与 rev;bundle 内容变更只经 `rebuilt()` 到达图。 ## bundle 路由与 index 注入 diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index 7c571ad152..98b6fa5019 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -777,7 +777,7 @@ export class ClientModuleRegistry extends Service { * module location is authoritative: the specifier resolves through the same * Loader resolution that imported the row's host half — including any * active ESM hooks — and the nearest ancestor manifest declaring the name - * owns the module. Config-anchor `require` resolution remains only for + * owns the module. Tree-anchored `require` resolution remains only for * runtimes without Node internals. * @param loaderName - module specifier of the loader row. * @param baseUrl - resolution base of the tree that owns the row. diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index a95b35d144..87f20b95e1 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -286,6 +286,57 @@ describe('client bundle activation', () => { expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) }) + it.each(['relative', 'absolute'] as const)( + 'finds the owning manifest through the %s-path fallback without Node loader internals', + (kind) => { + const packageName = `@fixture/${kind}-fallback-entry` + const clientPath = writePackage(packageName) + const packageRoot = dirname(dirname(clientPath)) + const hostPath = join(packageRoot, 'index.js') + mkdirSync(dirname(clientPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const loaderName = kind === 'relative' ? './index.js' : hostPath + + const { service } = constructWithRoute([loaderName], { + entryBaseUrl: pathToFileURL(packageRoot).href + '/', + }) + + expect(service.clientPath(packageName)).toBe(clientPath) + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }, + ) + + it.each(['v1', 'v2', 'worker'] as const)( + 'derives a file entry package id through the %s Loader resolver', + (version) => { + const packageName = `@fixture/file-entry-${version}` + const clientPath = writePackage(packageName) + const hostPath = join(dirname(clientPath), 'index.js') + mkdirSync(dirname(hostPath), { recursive: true }) + writeFileSync(hostPath, 'export default {}\n') + writeFileSync(clientPath, 'module.exports = {}\n') + const loaderName = pathToFileURL(hostPath).href + const entryBaseUrl = pathToFileURL(join(root!, 'overlay')).href + '/' + const calls: unknown[][] = [] + const resolveSync = (...args: unknown[]) => { + calls.push(args) + return { format: 'module' as const, url: loaderName } + } + const internal = { version, resolveSync } + + const { service } = constructWithRoute([loaderName], { + entryBaseUrl, + internal: internal as NonNullable, + }) + + expect(calls).toEqual(version === 'v2' + ? [[entryBaseUrl, { specifier: loaderName, attributes: {} }]] + : [[loaderName, entryBaseUrl, {}]]) + expect(service.graph().entries.map(entry => entry.id)).toEqual([packageName]) + }, + ) + it('rejects distinct active Loader sources for one browser package', () => { const packageName = '@fixture/duplicate-source' const clientPath = writePackage(packageName) diff --git a/packages/experimental/webworker-runtime/src/module-system/module-loader.ts b/packages/experimental/webworker-runtime/src/module-system/module-loader.ts index 5c5974f9d5..77e17e91b8 100644 --- a/packages/experimental/webworker-runtime/src/module-system/module-loader.ts +++ b/packages/experimental/webworker-runtime/src/module-system/module-loader.ts @@ -48,6 +48,7 @@ export type Resolution = /** Node-loader-compatible resolution returned through the Cordis internal seam. */ export interface WorkerInternalResolution { readonly format: 'builtin' | 'commonjs' | 'json' + /** File URL for VFS modules; the original bare specifier for builtins. */ readonly url: string } From b46953f3ccdff8c816503a1bf1c1088c79bdab8f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:16:51 +0800 Subject: [PATCH 092/130] test(client-modules): type loader resolver stubs --- packages/client/modules/tests/node-half.client.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 87f20b95e1..5553c94c8e 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -351,7 +351,7 @@ describe('client bundle activation', () => { } expect(() => constructWithRoute([packageName, alias], { - internal: internal as NonNullable, + internal: internal as unknown as NonNullable, })).toThrow( `client-modules: package ${packageName} resolves from multiple active Loader sources:`, ) @@ -371,7 +371,7 @@ describe('client bundle activation', () => { resolveSync: () => ({ format: 'module' as const, url: pathToFileURL(hostPath).href }), } const { context, service } = constructWithRoute(entries, { - internal: internal as NonNullable, + internal: internal as unknown as NonNullable, }) const firstRevision = service.graph().entries[0]!.rev const warning = vi.spyOn(context.logger, 'warn').mockImplementation(() => undefined) From 6ac1b829396cc570e449727a6c312b9c8853e4ea Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 27 Aug 2026 20:45:49 +0800 Subject: [PATCH 093/130] test(file-reference-local): pin the unreadable-subtree test to POSIX non-root chmod 0 can only deny directory reads on POSIX to a non-root owner: Windows exposes no directory permission bits for readdir, and root bypasses them. Where the fixture stays readable the sealed candidate is indexed, so the unreadable-branch behavior is pinned on POSIX non-root. An injected readdir failure keeps that branch covered on every platform. --- .../file-reference-local/tests/search.spec.ts | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/context/file-reference-local/tests/search.spec.ts b/packages/context/file-reference-local/tests/search.spec.ts index 7b48148bf3..1b50e812b4 100644 --- a/packages/context/file-reference-local/tests/search.spec.ts +++ b/packages/context/file-reference-local/tests/search.spec.ts @@ -9,6 +9,24 @@ import { WorkspaceFileSearch, } from '../src/search.ts' +const fsControl = vi.hoisted(() => ({ + /** Absolute path whose `readdir` rejects; the injectable stand-in for chmod 0. */ + denyReaddir: undefined as string | undefined, +})) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readdir: (async (path: unknown, ...rest: never[]) => { + if (fsControl.denyReaddir !== undefined && String(path) === fsControl.denyReaddir) { + throw Object.assign(new Error('EACCES: injected unreadable directory'), { code: 'EACCES' }) + } + return (actual.readdir as (path: unknown, ...args: never[]) => Promise)(path, ...rest) + }) as typeof actual.readdir, + } +}) + const searches: WorkspaceFileSearch[] = [] const roots: string[] = [] /** Permission-stripped directories; restored before cleanup can remove them. */ @@ -199,7 +217,13 @@ describe('WorkspaceFileSearch', () => { }) }) - it('lets an unreadable subtree cost only its own candidates', async () => { + // chmod 0 can only deny directory reads on POSIX to a non-root owner: + // Windows exposes no directory permission bits for readdir, and root + // bypasses them. Where the fixture stays readable the sealed candidate is + // indexed, so the unreadable-branch behavior is pinned on POSIX non-root. + it.runIf( + process.getuid !== undefined && process.getuid() !== 0, + )('lets an unreadable subtree cost only its own candidates', async () => { const root = await workspace() const locked = join(root, 'locked') await mkdir(locked, { recursive: true }) @@ -216,6 +240,29 @@ describe('WorkspaceFileSearch', () => { expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }]) }) + // The chmod-0 fixture above cannot be built on Windows (no directory + // permission bits) or as root (bits are bypassed). An injected readdir + // failure keeps the unreadable-branch behavior covered on every platform. + it('lets an injected readdir failure cost only its own candidates', async () => { + const root = await workspace() + const locked = join(root, 'locked') + await mkdir(locked, { recursive: true }) + await writeFile(join(locked, 'sealed.ts'), 'sealed') + fsControl.denyReaddir = locked + try { + const files = search(root) + const signal = new AbortController().signal + + // The branch itself yields nothing, and the rest of the tree still does. + expect(await files.list('sealed', signal)).toEqual([]) + expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }]) + // The directory is still offered: only reading through it fails. + expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }]) + } finally { + fsControl.denyReaddir = undefined + } + }) + it('enforces the entry cap', async () => { const root = await workspace() const capped = search(root, { maxEntries: 2 }) From 2d4393d842139f16f4ae32b8ae31476a597cdd22 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:06:58 +0800 Subject: [PATCH 094/130] refactor(api): expose remaining domain remotes --- packages/api/session-controller/package.json | 6 + .../session-controller/src/file-references.ts | 42 ++++ packages/api/session-controller/src/index.ts | 95 +++++++- .../session-controller/src/skill-catalog.ts | 120 +++++++++++ packages/api/session-controller/src/types.ts | 34 +++ .../api/session-controller/tsconfig.host.json | 8 +- packages/api/settings-controller/package.json | 8 +- packages/api/settings-controller/src/index.ts | 152 ++++++++++++- packages/api/settings-controller/src/types.ts | 10 + .../api/settings-controller/tsconfig.json | 9 + packages/context/file-reference/package.json | 16 +- packages/context/file-reference/src/index.ts | 22 +- packages/llm/llm-pi-ai/src/discovery.ts | 4 +- packages/llm/llm-pi-ai/src/index.ts | 5 +- packages/llm/llm/package.json | 19 +- packages/llm/llm/src/index.ts | 50 ++++- packages/llm/llm/src/types.ts | 14 ++ packages/llm/llm/tsconfig.json | 3 + packages/util/native-command/src/index.ts | 52 ++--- .../util/native-command/src/path-opener.ts | 203 ++++++++++++++++++ packages/util/native-command/src/runner.ts | 41 ++++ 21 files changed, 821 insertions(+), 92 deletions(-) create mode 100644 packages/api/session-controller/src/file-references.ts create mode 100644 packages/api/session-controller/src/skill-catalog.ts create mode 100644 packages/util/native-command/src/path-opener.ts create mode 100644 packages/util/native-command/src/runner.ts diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index dabfd8fd6a..191ad25909 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -85,15 +85,18 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", @@ -116,9 +119,11 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", @@ -126,6 +131,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", diff --git a/packages/api/session-controller/src/file-references.ts b/packages/api/session-controller/src/file-references.ts new file mode 100644 index 0000000000..44d3417928 --- /dev/null +++ b/packages/api/session-controller/src/file-references.ts @@ -0,0 +1,42 @@ +/** Session Controller adapter for Agent-scoped file-reference discovery. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-file-reference' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the `fileReferences` Remote namespace. */ + sessionFileReferences: SessionFileReferences + } +} + +/** Host Remote adapter over the composed file-reference provider. */ +export class SessionFileReferences extends TypertRemoteService { + static inject = ['fileReferences', 'typert'] + + /** @param ctx - Host context carrying the selected file-reference provider. */ + constructor(ctx: Context) { + super(ctx, 'sessionFileReferences', { namespace: 'fileReferences' }) + } + + /** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ + @Remote + list( + agent: Agent, + query: string, + signal: AbortSignal, + ): Promise { + return this.ctx.fileReferences.list(agent, query, signal) + } +} + +export default SessionFileReferences diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index a3ce201aaf..dadf1db607 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,10 +3,13 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' +import { openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { + ApiSessionNotFound, ApiSessionAgentController, inspectApiSession, type ApiSessionAgentResult, @@ -14,9 +17,13 @@ import { import { SessionCommandController } from './commands.ts' import { SessionControlController } from './control.ts' import { SessionHistoryController } from './history.ts' +import { SessionFileReferences } from './file-references.ts' import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' +import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' +import { SessionSkillCatalog } from './skill-catalog.ts' import type { + ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -30,6 +37,8 @@ import type { SessionForkValue, SessionListRequest, SessionListValue, + SessionOpenWorkspacePathRequest, + SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -46,6 +55,8 @@ import type { export type * from './types.ts' export { ApiSessionNotFound } from './agent.ts' +export { SessionFileReferences } from './file-references.ts' +export { SessionSkillCatalog } from './skill-catalog.ts' declare module '@deepseek-ai/cordis' { interface Context { @@ -60,6 +71,12 @@ export interface Config { readonly coldBlankProbeMaxBytes?: number } +/** Host integrations replaceable by direct unit tests. */ +export interface SessionControllerInternals { + /** Native default-application handoff. */ + readonly openPath?: (path: string, signal: AbortSignal) => Promise +} + /** Host service backing the generated `ctx.remote.session` namespace. */ export class SessionController extends TypertRemoteService { static inject = [ @@ -83,13 +100,14 @@ export class SessionController extends TypertRemoteService { private readonly controlState: SessionControlController private readonly history: SessionHistoryController private readonly listState: ApiSessionList + private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly promotions = new Set>() /** * @param ctx - Host context containing the Session capability assembly. * @param config - cold-list observation policy. */ - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) installModelSelectionProjection(ctx) this.agents = new ApiSessionAgentController(ctx) @@ -105,6 +123,9 @@ export class SessionController extends TypertRemoteService { ctx, config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, ) + this.openPath = internals.openPath ?? openNativePath + ctx.plugin(SessionFileReferences) + ctx.plugin(SessionSkillCatalog) ctx.on('session/created', (session) => { ctx.emit('api-session/added', this.listState.summaryFor(session)) @@ -214,6 +235,74 @@ export class SessionController extends TypertRemoteService { return this.commands.selectModel(request) } + /** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ + @Remote('modelCatalog') + modelCatalog(): Promise { + return buildModelCatalog(this.ctx) + } + + /** + * Open a path resolved against one Session's workspace on the Host desktop. + * @param request - Session identity and absolute or workspace-relative path. + * @param signal - caller lifetime; abort terminates inspection or the native command. + * @returns confirmation after the native opener accepts the path. + * @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. + */ + @Remote('openWorkspacePath') + async openWorkspacePath( + request: SessionOpenWorkspacePathRequest, + signal: AbortSignal, + ): Promise { + if (request.path.length === 0) { + throw new TypertRemoteFailure({ + code: 'bad-request', + message: 'session.openWorkspacePath requires a non-empty path', + details: {}, + }) + } + signal.throwIfAborted() + let cwd: string | undefined + try { + cwd = (await this.inspect(request.sessionId, signal)).meta.cwd + } catch (error: unknown) { + if (signal.aborted) { + throw new TypertRemoteFailure({ + code: 'cancelled', message: 'path open was aborted', details: {}, + }) + } + if (error instanceof ApiSessionNotFound) { + throw new TypertRemoteFailure({ + code: 'session-not-found', + message: error.message, + details: { sessionId: request.sessionId }, + }) + } + throw new TypertRemoteFailure({ + code: 'internal', + message: `session "${request.sessionId}" could not be inspected: ${String(error)}`, + details: {}, + }) + } + try { + await this.openPath(resolveWorkspacePath(cwd, request.path), signal) + return { opened: true } + } catch (error: unknown) { + if (signal.aborted) { + throw new TypertRemoteFailure({ + code: 'cancelled', message: 'path open was aborted', details: {}, + }) + } + throw new TypertRemoteFailure({ + code: 'internal', + message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, + details: {}, + }) + } + } + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. @@ -310,5 +399,5 @@ export class SessionController extends TypertRemoteService { } -export { buildModelCatalog } from './catalog.ts' +export { buildModelCatalog } export default SessionController diff --git a/packages/api/session-controller/src/skill-catalog.ts b/packages/api/session-controller/src/skill-catalog.ts new file mode 100644 index 0000000000..3cd15669ff --- /dev/null +++ b/packages/api/session-controller/src/skill-catalog.ts @@ -0,0 +1,120 @@ +/** Session-addressed, cold-readable skill catalog Remote. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent-presets/types' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import { isUserInvocable } from '@deepseek-ai/dsh-skill' +import type { ScopeKey } from '@deepseek-ai/dsh-scope' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import type { SkillListRequest, SkillListValue } from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host owner of the Session-addressed `skills` Remote namespace. */ + sessionSkillCatalog: SessionSkillCatalog + } +} + +/** Host service backing `ctx.remote.skills` without activating a cold Agent. */ +export class SessionSkillCatalog extends TypertRemoteService { + static inject = ['agents', 'sessionQuery', 'typert'] + + /** @param ctx - Host context carrying Session reads and optional skill/preset services. */ + constructor(ctx: Context) { + super(ctx, 'sessionSkillCatalog', { namespace: 'skills' }) + } + + /** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + */ + @Remote + async list(request: SkillListRequest, signal: AbortSignal): Promise { + void signal + const { sessionId } = request + let cwd: string | undefined + let agentPreset: string | undefined + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId) + if (observation.projections === undefined) { + throw new Error('skill catalog requires a projected Session observation') + } + cwd = observation.header.cwd + agentPreset = observation.projections.values.agentPreset ?? undefined + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw failure( + 'session-not-found', + `session "${sessionId}" not found`, + { sessionId }, + ) + } + throw failure( + 'internal', + `session "${sessionId}" could not be inspected: ${String(error)}`, + ) + } + if (cwd === undefined) { + throw failure('internal', `session "${sessionId}" has no project cwd`) + } + + const live = this.ctx.agents.get(sessionId) + const presets = this.ctx.get('agentPresets') + const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') + const skillRegistry = scoped ?? this.ctx.get('skills') + if (skillRegistry === undefined) { + throw failure( + 'internal', + 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', + ) + } + + const scope = await this.scopeFor(sessionId, agentPreset) + try { + const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) + return { + skills: skills.map(skill => ({ + name: skill.name, + description: skill.description, + ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, + modelInvocable: skill.invocation.modelInvocable, + })), + } + } catch (error: unknown) { + throw failure('internal', `skill listing failed: ${String(error)}`) + } + } + + /** Resolve a live or standing preset scope without creating an Agent. */ + private async scopeFor( + sessionId: SessionId, + agentPreset: string | undefined, + ): Promise { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) return live + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return undefined + try { + return await presets.standingKeyFor(agentPreset) + } catch { + // An unknown or unusable recorded preset falls back to the global registry. + return undefined + } + } +} + +/** Build one stable Remote failure with optional typed details. */ +function failure( + code: 'session-not-found' | 'internal', + message: string, + details: { readonly sessionId: SessionId } | Record = {}, +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +export default SessionSkillCatalog diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index e9e416777c..24e9232d07 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -223,6 +223,28 @@ export type SessionError = { } }[keyof SessionErrorDetailsMap] +/** Session-addressed request for the human-invocable skill catalog. */ +export interface SkillListRequest { + readonly sessionId: SessionId +} + +/** One skill available to the Session's human-facing composer. */ +export interface SkillEntry { + /** Kebab-case identifier referenced as `/name`. */ + readonly name: string + /** Short routing description. */ + readonly description: string + /** Optional extra routing guidance. */ + readonly whenToUse?: string + /** Whether the same skill is also advertised to the model. */ + readonly modelInvocable: boolean +} + +/** Human-invocable skills visible through one Session's composition. */ +export interface SkillListValue { + readonly skills: readonly SkillEntry[] +} + /** Session list request. */ export interface SessionListRequest { readonly cursor?: string @@ -340,6 +362,18 @@ export interface SessionCancelValue { readonly accepted: true } +/** Session-addressed request to open one workspace path on the Host desktop. */ +export interface SessionOpenWorkspacePathRequest { + readonly sessionId: SessionId + /** Absolute or Session-workspace-relative path. */ + readonly path: string +} + +/** Confirmation that the Host handed a workspace path to its native opener. */ +export interface SessionOpenWorkspacePathValue { + readonly opened: true +} + /** Client-minted prompt identity used to reconcile optimistic and durable messages. */ export type SessionRequestId = Branded<'session-request-id'> diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index d3256426e2..e3f40c885d 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -14,9 +14,11 @@ "src/catalog.ts", "src/commands.ts", "src/control.ts", + "src/file-references.ts", "src/history.ts", "src/list.ts", - "src/model-selection-projection.ts" + "src/model-selection-projection.ts", + "src/skill-catalog.ts" ], "references": [ { "path": "../../../vendor/cordis" }, @@ -25,10 +27,12 @@ { "path": "../../core/agent-default-model" }, { "path": "../../core/scope" }, { "path": "../../core/session" }, + { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, + { "path": "../../util/native-command" }, { "path": "../../preset/agent-presets" }, { "path": "../../runtime-diagnostics/invariants" }, { "path": "../../session/session-persistence" }, @@ -36,9 +40,11 @@ { "path": "../../session/session-projection-cache" }, { "path": "../../session/session-title" }, { "path": "../../session-query/session-query" }, + { "path": "../../skill/skill" }, { "path": "../../subagent/subagent" }, { "path": "../../typert/protocol" }, { "path": "../../typert/registry" }, + { "path": "../../util/workspace-path" }, { "path": "../../workspace/workspace" } ] } diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index ed518756fb..4f4326f47e 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -49,22 +49,28 @@ ], "license": "MIT", "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index e893d06649..a61dfd329e 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -7,7 +7,20 @@ * @module @deepseek-ai/dsh-api-settings-controller */ +import { dirname } from 'node:path' import { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' +import { + InvalidPresetIdError, + PresetExistsError, + PresetNotWritableError, + UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' +import { + canOpenNativePath, + openNativePath, + openNativeTextFile, +} from '@deepseek-ai/dsh-native-command' import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings' import type { @@ -17,12 +30,26 @@ import type { JsonValue } from '@deepseek-ai/dsh-session/types' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { CredentialsController } from './credentials.ts' +import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts' export { CredentialsController } from './credentials.ts' export type * from './types.ts' const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) +/** Native document-opening policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} + +/** Host integrations replaceable by direct unit tests. */ +export interface SettingsControllerInternals { + readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly openTextFile?: (path: string, signal: AbortSignal) => Promise + readonly canOpenPath?: () => boolean +} + /** * Project one redacted descriptor onto its wire view, field by field. The * Gateway returns a business result without decoding it, so a provider whose @@ -59,14 +86,24 @@ declare module '@deepseek-ai/cordis' { * `settings-conflict` or `settings-rejected` with the service's message. */ export class SettingsController extends TypertRemoteService { + static Config: Schema = Schema.object({ nativeOpen: Schema.boolean() }) + + private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly openTextFile: (path: string, signal: AbortSignal) => Promise + private readonly canOpenPath: () => boolean + /** * Register the settings namespace and mount the credentials namespace beside * it. Both namespaces stay registered when a provider is absent so calls can * return the configuration API's actionable missing-provider diagnostic. * @param ctx - Host context where settings and credential providers may be mounted. */ - constructor(ctx: Context) { + constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) { super(ctx, 'settingsController', { namespace: 'settings' }) + this.openPath = internals.openPath ?? openNativePath + this.openTextFile = internals.openTextFile ?? openNativeTextFile + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(CredentialsController) } @@ -139,6 +176,81 @@ export class SettingsController extends TypertRemoteService { return this.write(ns, 'mutate', ops, expectedRevision) } + /** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + */ + @Remote + async openSettingsDocument(signal: AbortSignal): Promise { + const settings = this.provider() + if (signal.aborted) throw cancelled('settings document open was aborted') + let path: string | undefined + try { + path = await settings.prepareDocument() + } catch (error: unknown) { + if (signal.aborted) throw cancelled('settings document preparation was aborted') + throw internal(`settings document preparation failed: ${messageOf(error)}`) + } + if (path === undefined) { + throw internal('settings provider has no local document to open') + } + if (signal.aborted) throw cancelled('settings document open was aborted') + try { + await this.openTextFile(path, signal) + return { opened: true } + } catch (error: unknown) { + if (signal.aborted) throw cancelled('settings document open was aborted') + throw internal(`path open failed: ${messageOf(error)}`) + } + } + + /** + * Open one user-authored Agent preset directory or return its path when no native opener exists. + * @param agentPreset - preset id resolved against Host-owned roots. + * @param signal - caller lifetime; abort terminates the native command. + * @returns an opened confirmation or the resolved directory for text display. + * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + */ + @Remote + async openAgentPresetDirectory( + agentPreset: string, + signal: AbortSignal, + ): Promise { + if (agentPreset.length === 0) { + throw new TypertRemoteFailure({ + code: 'bad-request', message: 'agent preset id must not be empty', details: {}, + }) + } + const presets = this.ctx.get('agentPresets') + if (presets === undefined) { + throw new TypertRemoteFailure({ + code: 'agent-preset-not-found', + message: 'this deployment composes no agent presets', + details: { agentPreset, available: [] }, + }) + } + let directory: string + try { + const preset = await presets.resolve(agentPreset) + if (preset.trust !== 'user') { + throw new PresetNotWritableError(preset.id, 'it ships with the deployment') + } + directory = dirname(preset.path) + } catch (error: unknown) { + throw presetFailure(agentPreset, error) + } + if (!this.canOpenPath()) return { opened: false, path: directory } + try { + await this.openPath(directory, signal) + return { opened: true } + } catch (error: unknown) { + if (signal.aborted) throw cancelled('path open was aborted') + throw internal(`path open failed: ${messageOf(error)}`) + } + } + private async write( ns: string, mode: 'update' | 'replace' | 'mutate', @@ -196,6 +308,44 @@ export class SettingsController extends TypertRemoteService { } } +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function internal(message: string): TypertRemoteFailure { + return new TypertRemoteFailure({ code: 'internal', message, details: {} }) +} + +function cancelled(message: string): TypertRemoteFailure { + return new TypertRemoteFailure({ code: 'cancelled', message, details: {} }) +} + +function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure { + if (error instanceof UnknownPresetError) { + return new TypertRemoteFailure({ + code: 'agent-preset-not-found', + message: error.message, + details: { agentPreset: error.presetId, available: [...error.available] }, + }) + } + if (error instanceof PresetNotWritableError) { + return new TypertRemoteFailure({ + code: 'agent-preset-read-only', + message: error.message, + details: { agentPreset, reason: error.message }, + }) + } + if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { + return new TypertRemoteFailure({ + code: 'agent-preset-invalid', + message: error.message, + details: { agentPreset, reason: error.message }, + }) + } + if (error instanceof TypertRemoteFailure) return error + return internal(`agent preset "${agentPreset}": ${String(error)}`) +} + /** * Classify one seam refusal. A stale writer is its own outcome, not a malformed * request: the client must re-read and re-apply rather than treat the write as diff --git a/packages/api/settings-controller/src/types.ts b/packages/api/settings-controller/src/types.ts index 81249e3065..5fde28ae89 100644 --- a/packages/api/settings-controller/src/types.ts +++ b/packages/api/settings-controller/src/types.ts @@ -30,6 +30,16 @@ export type SettingsError = { } }[keyof SettingsErrorDetailsMap] +/** Confirmation that the settings document was handed to the native editor. */ +export interface SettingsDocumentOpenValue { + readonly opened: true +} + +/** Result of opening or revealing one locally authored Agent preset directory. */ +export type AgentPresetDirectoryOpenValue = + | { readonly opened: true } + | { readonly opened: false; readonly path: string } + /** Stable credential failure details returned by the `credentials` namespace. */ export interface CredentialErrorDetailsMap { /** diff --git a/packages/api/settings-controller/tsconfig.json b/packages/api/settings-controller/tsconfig.json index 5b854dbe64..763aa37777 100644 --- a/packages/api/settings-controller/tsconfig.json +++ b/packages/api/settings-controller/tsconfig.json @@ -11,6 +11,12 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../preset/agent-presets" + }, { "path": "../../credentials/credentials" }, @@ -20,6 +26,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../util/native-command" + }, { "path": "../../settings/settings" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 7d34d416cf..f1e45418b6 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -30,14 +30,6 @@ "types": "./lib/types/types.d.ts", "default": "./lib/types/types.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" }, @@ -45,23 +37,17 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts", - "lib/typert.host.js", - "lib/typert.host.d.ts", - "lib/typert.remote-client.js", - "lib/typert.remote-client.d.ts" + "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { diff --git a/packages/context/file-reference/src/index.ts b/packages/context/file-reference/src/index.ts index 45da48c929..bf1025714f 100644 --- a/packages/context/file-reference/src/index.ts +++ b/packages/context/file-reference/src/index.ts @@ -4,9 +4,8 @@ * @module @deepseek-ai/dsh-file-reference */ -import type { Context } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { FileReferenceCandidate } from './types.ts' @@ -24,7 +23,7 @@ declare module '@deepseek-ai/cordis' { } /** Host capability for cancellable file-reference discovery. */ -export abstract class FileReferenceService extends TypertRemoteService { +export abstract class FileReferenceService extends Service { constructor(ctx: Context) { super(ctx, 'fileReferences') } @@ -41,23 +40,6 @@ export abstract class FileReferenceService extends TypertRemoteService { query: string, signal: AbortSignal, ): Promise - - /** - * Remote face of {@link list}; the decorator cannot mark the abstract - * member, so this concrete adapter carries the identical contract. - * @param agent - target agent whose session cwd bounds discovery. - * @param query - path text following `@` or `@"`. - * @param signal - caller cancellation. - * @returns deterministic path-only candidates. - */ - @Remote('list') - remoteExportList( - agent: Agent, - query: string, - signal: AbortSignal, - ): Promise { - return this.list(agent, query, signal) - } } export default FileReferenceService diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index 014c9c2f3e..bb9915b116 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -23,7 +23,7 @@ */ import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm' -import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm' +import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm' import { attributionHeaders } from '@deepseek-ai/dsh-llm' import { catalogModels } from './catalog.ts' @@ -193,7 +193,7 @@ function usableProbeKey(raw: string): string { * refuses or fails the request, or the reply is not a model listing. */ export async function discoverModels( - request: LlmModelDiscoveryRequest, + request: LlmModelDiscoveryOperation, storedApiKey?: () => Promise, ): Promise { // A catalog route already has its answer, and a better one: the installed diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index c9752b764e..58d5f620c8 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -257,7 +257,10 @@ export function apply(ctx: Context, config: Config): void { // except the credential: a configuration surface edits a redacted descriptor // and never holds a stored secret, so an already-configured route supplies // its own here rather than being interrogated unauthenticated. - ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider))) + ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels( + { ...request, ...signal === undefined ? {} : { signal } }, + () => storedApiKey(request.provider), + )) // Route effects bind to this apply fiber via the stable `ctx` reference, // even when a swap runs inside the scoped settings callback below. A bare // mount (zero routes) is the dormant posture: nothing registers until a diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index cf032be43e..1ec340226e 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -34,6 +34,14 @@ "types": "./lib/types/message.d.ts", "default": "./lib/types/message.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" }, @@ -41,7 +49,11 @@ "lib/index.js", "lib/invariant.js", "lib/types/**/*.js", - "lib/types/**/*.d.ts" + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" ], "license": "MIT", "peerDependencies": { @@ -49,17 +61,20 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index c1ec12e3c9..37b6795a13 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -6,7 +6,8 @@ * @module @deepseek-ai/dsh-llm */ -import { Context, Service } from '@deepseek-ai/cordis' +import { Context } from '@deepseek-ai/cordis' +import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { GenerateOptions, LlmConfigurableProvider, @@ -322,12 +323,12 @@ export interface DirectoryRegistrationHandle { * The abstract `llm` service: an adapter registry plus a streaming model-call * API, interceptable via the `llm/stream` waterfall. */ -export class LlmRuntime extends Service { +export class LlmRuntime extends TypertRemoteService { private adapters = new Map() private directory = new Map() private discoveries = new Map< string, - (request: LlmModelDiscoveryRequest) => Promise + (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise >() constructor(ctx: Context) { @@ -457,6 +458,7 @@ export class LlmRuntime extends Service { * Describe provider routes with a registered adapter. * @returns detached provider metadata in registration order. */ + @Remote listProviders(): LlmProviderInfo[] { return [...this.adapters.values()].map(({ provider }) => ({ ...provider })) } @@ -528,6 +530,7 @@ export class LlmRuntime extends Service { * List every declared configurable provider, registered or dormant. * @returns detached directory entries in declaration order. */ + @Remote listConfigurableProviders(): LlmConfigurableProvider[] { return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] })) } @@ -539,12 +542,15 @@ export class LlmRuntime extends Service { * directory, and because a provider being *added* has no route to name yet. * Disposed with the fiber. * @param settingsNs - the namespace whose profiles this discovery serves. - * @param discover - interrogates one endpoint; must honor `request.signal`. + * @param discover - interrogates one endpoint and must honor the supplied signal. * @returns the disposer that withdraws the offer. */ registerModelDiscovery( settingsNs: string, - discover: (request: LlmModelDiscoveryRequest) => Promise, + discover: ( + request: LlmModelDiscoveryRequest, + signal?: AbortSignal, + ) => Promise, ): () => void { const dispose = this.ctx.effect(function* (this: LlmRuntime) { if (settingsNs.length === 0) { @@ -568,11 +574,13 @@ export class LlmRuntime extends Service { * candidate metadata a surface may offer for adoption. * @param settingsNs - namespace whose registered discovery serves this draft. * @param request - the endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation. * @returns the advertised models, deduplicated in endpoint order. */ async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, + signal?: AbortSignal, ): Promise { const discover = this.discoveries.get(settingsNs) if (discover === undefined) { @@ -583,7 +591,9 @@ export class LlmRuntime extends Service { if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) { throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY') } - const discovered = await discover(request) + const discovered = signal === undefined + ? await discover(request) + : await discover(request, signal) const seen = new Set() const models: LlmDiscoveredModel[] = [] for (const model of discovered) { @@ -599,6 +609,34 @@ export class LlmRuntime extends Service { return models } + /** + * Remote adapter for one draft provider interrogation. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation supplied by the Remote carrier. + * @returns advertised models in endpoint order. + * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + */ + @Remote('discoverModels') + async remoteDiscoverModels( + settingsNs: string, + request: LlmModelDiscoveryRequest, + signal: AbortSignal, + ): Promise { + try { + return await this.discoverModels(settingsNs, request, signal) + } catch (error: unknown) { + throw new TypertRemoteFailure({ + code: 'model-discovery-failed', + message: error instanceof Error ? error.message : String(error), + details: { + settingsNs, + ...request.baseURL === undefined ? {} : { baseURL: request.baseURL }, + }, + }) + } + } + /** * Resolve the retry policy captured when one provider route was registered. * @param provider - registered provider route to inspect. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index e7dadc6b20..438fcaa67b 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -247,10 +247,24 @@ export interface LlmModelDiscoveryRequest { api?: string /** Credential for this interrogation alone; the harness never stores it. */ apiKey?: string +} + +/** Provider-side discovery request with operation-local cancellation attached. */ +export interface LlmModelDiscoveryOperation extends LlmModelDiscoveryRequest { /** Caller cancellation; implementations must settle promptly after it aborts. */ signal?: AbortSignal } +/** Stable failure returned by the `llm/discoverModels` Remote method. */ +export interface LlmModelDiscoveryError { + readonly code: 'model-discovery-failed' + readonly message: string + readonly details: { + readonly settingsNs: string + readonly baseURL?: string + } +} + /** * One model an endpoint reports about itself. Every field but the id is * optional because most provider listings disclose an id and nothing else; diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 2206a6028e..f561d7b5e0 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -28,6 +28,9 @@ }, { "path": "../../util/crypto" + }, + { + "path": "../../typert/protocol" } ] } diff --git a/packages/util/native-command/src/index.ts b/packages/util/native-command/src/index.ts index 8ad8b81749..14100af799 100644 --- a/packages/util/native-command/src/index.ts +++ b/packages/util/native-command/src/index.ts @@ -1,44 +1,16 @@ /** - * Shared no-shell `execFile` runner for host-native OS integrations (the - * native directory chooser, the open-with-default-application hand-off): - * utf8 stdio capture, abort propagation, Windows console hide. A library, - * not a plugin — no ctx, no state, no events. + * Host-native command execution and path-opening utilities. * @module @deepseek-ai/dsh-native-command */ -import { execFile } from 'node:child_process' - -/** Testable command boundary; native implementations never invoke a shell. */ -export type NativeCommandRunner = ( - command: string, - args: readonly string[], - signal: AbortSignal, -) => Promise<{ stdout: string; stderr: string }> - -/** - * Run a host command with utf8 stdio, abort propagation, and Windows hide. - * @param command - executable path or PATH name. - * @param args - argv (never a shell string). - * @param signal - caller/connection lifetime; abort terminates the child. - * @returns captured stdout/stderr on exit 0. - */ -export const runNativeCommand: NativeCommandRunner = (command, args, signal) => - new Promise((resolve, reject) => { - execFile( - command, - [...args], - { encoding: 'utf8', signal, windowsHide: true }, - (error, stdout, stderr) => { - if (error !== null) { - const failure = Object.assign(new Error(error.message, { cause: error }), { - code: error.code, - stdout, - stderr, - }) - reject(failure) - return - } - resolve({ stdout, stderr }) - }, - ) - }) +export { runNativeCommand } from './runner.ts' +export type { NativeCommandRunner } from './runner.ts' +export { + canOpenNativePath, + openNativePath, + openNativeTextFile, +} from './path-opener.ts' +export type { + PathOpenerInternals, + PathOpenerRunner, +} from './path-opener.ts' diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts new file mode 100644 index 0000000000..97d2084e61 --- /dev/null +++ b/packages/util/native-command/src/path-opener.ts @@ -0,0 +1,203 @@ +/** + * Cross-platform native path and text-document openers for Host UI + * integrations. + * + * The default intent prefers the default browser for documents it renders when + * the platform can name one, then falls back to the default application. WSL + * translates every path for the Windows desktop instead of assuming a Linux + * GUI. The text-editor intent never consults the browser. + * @module @deepseek-ai/dsh-native-command/path-opener + */ + +import { release as osRelease } from 'node:os' +import { extname } from 'node:path' +import { runNativeCommand, type NativeCommandRunner } from './runner.ts' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type PathOpenerRunner = NativeCommandRunner + +/** Injectable platform facts for deterministic adapter tests. */ +export interface PathOpenerInternals { + platform?: NodeJS.Platform + /** Kernel release override used to distinguish WSL from desktop Linux. */ + osRelease?: string + /** Environment used for WSL markers and the desktop Linux browser convention. */ + env?: NodeJS.ProcessEnv + run?: PathOpenerRunner +} + +/** Documents a browser renders, as opposed to ones an editor merely edits. */ +const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * The macOS bundle registered for `https` — the default browser, as + * LaunchServices records it. The nested version dict is stripped first + * because it carries its own `LSHandlerRoleAll`. + */ +function macBundleForHttps(plist: string): string | undefined { + const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '') + const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0] + if (block === undefined) return undefined + return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1] +} + +/** + * Open one browser-renderable document with the default browser. + * @returns true when a browser took it; false when this platform cannot name + * one, or naming it failed — the caller then uses the default application. + */ +async function openInBrowser( + path: string, signal: AbortSignal, platform: NodeJS.Platform, + run: PathOpenerRunner, env: NodeJS.ProcessEnv, +): Promise { + if (platform === 'darwin') { + let bundle: string | undefined + try { + const { stdout } = await run( + 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal) + bundle = macBundleForHttps(stdout) + } catch { + // No LaunchServices record (a fresh account never changed a default): + // the content-type handler is then the system's own choice anyway. + return false + } + if (bundle === undefined) return false + await run('open', ['-b', bundle, path], signal) + return true + } + if (platform === 'linux') { + // $BROWSER is the portable convention; desktop-entry resolution through + // xdg-settings needs a launcher this package has no business shipping. + const browser = env.BROWSER + if (browser === undefined || browser === '') return false + await run(browser, [path], signal) + return true + } + // Windows names no browser without reading the UserChoice registry, and its + // .html association is the browser in the ordinary case. + return false +} + +/** Native path-open intent; macOS distinguishes text editing from file association. */ +type PathOpenIntent = 'default' | 'text-editor' + +/** PowerShell single-quoted literal (doubles embedded quotes). */ +function powershellLiteral(path: string): string { + return `'${path.replace(/'/g, "''")}'` +} + +/** Whether one environment marker is set to a non-empty value. */ +function present(value: string | undefined): boolean { + return value !== undefined && value !== '' +} + +/** Distinguish WSL from desktop Linux using its process and kernel markers. */ +function isWsl(internals: PathOpenerInternals): boolean { + const env = internals.env ?? process.env + if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true + return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') +} + +/** Open one Windows-resolvable path through its registered desktop application. */ +async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { + await run('powershell.exe', [ + '-NoProfile', + '-Command', + `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, + ], signal) +} + +/** Translate a WSL path before handing it to the Windows desktop. */ +async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { + const translated = await run('wslpath', ['-w', path], signal) + signal.throwIfAborted() + const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') + if (windowsPath === '') throw new Error('wslpath returned no Windows path') + await openWindowsPath(windowsPath, signal, run) +} + +/** Dispatch one shell-free platform command for the requested open intent. */ +async function openNativePathWithIntent( + path: string, + signal: AbortSignal, + intent: PathOpenIntent, + internals: PathOpenerInternals = {}, +): Promise { + const platform = internals.platform ?? process.platform + const run = internals.run ?? runNativeCommand + const env = internals.env ?? process.env + const wsl = platform === 'linux' && isWsl(internals) + + if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase()) + && await openInBrowser(path, signal, platform, run, env)) return + + if (platform === 'darwin') { + await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal) + return + } + + if (platform === 'win32') { + await openWindowsPath(path, signal, run) + return + } + + if (platform === 'linux') { + if (wsl) { + await openWslPath(path, signal, run) + return + } + await run('xdg-open', [path], signal) + return + } + + throw new Error(`native path opener is unsupported on ${platform}`) +} + +/** + * Whether {@link openNativePath} plausibly reaches a desktop on this host. + * + * macOS and Windows always carry a desktop opener; Linux does when it is WSL + * (the Windows desktop takes the path) or a display server is announced. + * A headless or containerised Linux host answers false, which is what lets a + * surface show a path as text instead of offering a button that would spawn + * `xdg-open` into nothing. + * @param internals - platform and environment seam for deterministic tests. + * @returns true when handing a path to the native opener can work at all. + */ +export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean { + const platform = internals.platform ?? process.platform + if (platform === 'darwin' || platform === 'win32') return true + if (platform !== 'linux') return false + const env = internals.env ?? process.env + return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY) +} + +/** + * Open a filesystem path with the operating system's default application, or + * with the default browser when the path names a document a browser renders. + * @param path - absolute or host-resolvable path (caller owns resolution). + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - Platform, environment, and runner hooks for deterministic tests. + */ +export function openNativePath( + path: string, + signal: AbortSignal, + internals: PathOpenerInternals = {}, +): Promise { + return openNativePathWithIntent(path, signal, 'default', internals) +} + +/** + * Open a text document for editing; macOS bypasses the file-type association + * so a YAML association with a browser cannot consume the gesture. + * @param path - absolute or host-resolvable text-document path. + * @param signal - caller/connection lifetime; abort terminates the native command. + * @param internals - Platform and runner hooks for deterministic tests. + */ +export function openNativeTextFile( + path: string, + signal: AbortSignal, + internals: PathOpenerInternals = {}, +): Promise { + return openNativePathWithIntent(path, signal, 'text-editor', internals) +} diff --git a/packages/util/native-command/src/runner.ts b/packages/util/native-command/src/runner.ts new file mode 100644 index 0000000000..58003baa55 --- /dev/null +++ b/packages/util/native-command/src/runner.ts @@ -0,0 +1,41 @@ +/** + * Shared no-shell `execFile` runner for host-native OS integrations. + * @module @deepseek-ai/dsh-native-command/runner + */ + +import { execFile } from 'node:child_process' + +/** Testable command boundary; native implementations never invoke a shell. */ +export type NativeCommandRunner = ( + command: string, + args: readonly string[], + signal: AbortSignal, +) => Promise<{ stdout: string; stderr: string }> + +/** + * Run a host command with utf8 stdio, abort propagation, and Windows hide. + * @param command - executable path or PATH name. + * @param args - argv (never a shell string). + * @param signal - caller/connection lifetime; abort terminates the child. + * @returns captured stdout/stderr on exit 0. + */ +export const runNativeCommand: NativeCommandRunner = (command, args, signal) => + new Promise((resolve, reject) => { + execFile( + command, + [...args], + { encoding: 'utf8', signal, windowsHide: true }, + (error, stdout, stderr) => { + if (error !== null) { + const failure = Object.assign(new Error(error.message, { cause: error }), { + code: error.code, + stdout, + stderr, + }) + reject(failure) + return + } + resolve({ stdout, stderr }) + }, + ) + }) From 5b2f679e4a32f074fad5c2f48164473753184570 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:08:09 +0800 Subject: [PATCH 095/130] refactor(client): consume migrated Remote namespaces --- packages/api/remotes/src/client/index.ts | 20 ++- packages/client/connection/src/client/api.ts | 3 - .../client/connection/src/client/fixture.ts | 157 +++++++++--------- .../client/connection/src/client/index.ts | 3 - .../ui-agent-preset/src/client/index.ts | 2 +- .../src/client/section-store.ts | 17 +- packages/client/ui-chat/package.json | 4 +- packages/client/ui-chat/src/client/apply.ts | 11 +- packages/client/ui-chat/tsconfig.json | 3 - .../client/ui-model-selection/package.json | 3 - .../ui-model-selection/src/client/catalog.ts | 19 +-- .../ui-model-selection/src/client/index.ts | 2 +- .../ui-model-selection/src/client/service.ts | 7 +- .../ui-settings-general/src/client/index.ts | 4 +- .../src/client/settings-document-store.ts | 8 +- .../client/ui-settings-models/package.json | 2 - .../src/client/ModelListEditor.tsx | 19 +-- .../ui-settings-models/src/client/index.ts | 14 +- .../src/client/slot-contract.ts | 6 +- .../ui-settings-models/src/client/store.ts | 76 +++++++-- .../ui-settings-plugins/src/client/index.ts | 8 +- ...ubagent-model-selection-card-controller.ts | 14 +- packages/client/ui-skill/src/client/index.ts | 12 +- .../client/ui-workspace/src/client/index.ts | 2 +- .../ui-workspace/src/client/navigation.ts | 15 -- 25 files changed, 219 insertions(+), 212 deletions(-) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 8638c54b9f..d40e9f65fc 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -5,8 +5,8 @@ import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' -import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' @@ -20,8 +20,8 @@ export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inven export type {} from '@deepseek-ai/dsh-agent-presets/remote' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' -export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' export type {} from '@deepseek-ai/dsh-session-reference/remote' @@ -54,11 +54,10 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - DiscoveredModelView, IApiClient, - MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + ConnectionHandle, ConnectionSinks, ContentBlock, IApiClient, + MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, - SkillEntry, StreamChunk, + StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -111,6 +110,11 @@ export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' export type { SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, } from '@deepseek-ai/dsh-settings/types' +// Provider registry and discovery vocabulary for the llm namespace. +export type { + LlmConfigurableProvider, LlmDiscoveredModel, LlmModelDiscoveryError, + LlmModelDiscoveryRequest, LlmProviderInfo, +} from '@deepseek-ai/dsh-llm/types' // Reference-discovery result vocabulary for the fileReferences and // sessionReferenceResolver namespaces. export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' @@ -123,6 +127,7 @@ export type ClientFailure = | import('@deepseek-ai/dsh-api-session-controller/types').SessionError | import('@deepseek-ai/dsh-api-settings-controller/types').CredentialError | import('@deepseek-ai/dsh-api-settings-controller/types').SettingsError + | import('@deepseek-ai/dsh-llm/types').LlmModelDiscoveryError | import('@deepseek-ai/dsh-subagent/client').SubagentControlError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError @@ -150,8 +155,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, dynamicRemote, - fileReferencesRemote, + agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, subagentsRemote, sessionRemote, workspaceRemote, ]) { diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 4d4826da79..0ae8af9ddf 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -8,11 +8,8 @@ export type { ApiProxy, HostApi, ResponseValue, - SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, - SettingsApi, - ConfigurableProviderView, DiscoveredModelView, LlmApi, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 6cc57082cb..a5689069c2 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -551,7 +551,7 @@ const OPENAI_REASONING = { defaultEffort: 'medium', } -/** Catalog served by `llm.models` (fresh copies per call). */ +/** Catalog served by `session/modelCatalog` (fresh copies per call). */ function fixtureModelGroups(): ModelProviderGroup[] { return [ { @@ -1834,6 +1834,25 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } }, + openSettingsDocument(): RpcResult<{ opened: true }> { + return { ok: true, value: { opened: true } } + }, + openAgentPresetDirectory(agentPreset: string): RpcResult< + { opened: true } | { opened: false; path: string } + > { + const existing = fixturePresets.get(agentPreset) + if (existing === undefined || existing.trust === 'system') { + return { + ok: false, + error: { + code: 'agent-preset-read-only', + message: `agent preset "${agentPreset}" ships with the deployment`, + details: { agentPreset, reason: 'it ships with the deployment' }, + }, + } + } + return { ok: true, value: { opened: true } } + }, } const credentialRemotes = { @@ -1999,10 +2018,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { function ok(request: RpcRequest

    , value: T): Promise> { return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) } - function err(request: RpcRequest

    , error: Extract, { ok: false }>['error']): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } }) - } - function sessionOk(value: T): Promise> { return Promise.resolve({ ok: true, value }) } @@ -2012,16 +2027,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const summaryOf = (id: SessionId): FixtureSessionSummary | undefined => sessions.find(s => s.sessionId === id) - /** Shared session guard for sessionId-addressed catalog routes: the error - * response when the session is unknown, undefined when it exists. */ - const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise> | undefined => { - if (summaryOf(request.payload.sessionId) !== undefined) return undefined - return err<{ sessionId: SessionId }, never>(request, { - code: 'session-not-found', - message: `no session ${request.payload.sessionId}`, - details: { sessionId: request.payload.sessionId }, - }) - } const requireRemoteSession = ( request: { readonly sessionId: SessionId }, ): Promise> | undefined => { @@ -3398,65 +3403,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, }), - openPath: request => ok(request, { opened: true as const }), - }, - agentPresets: { - // Native opens are deterministic no-op successes in this fixture, so the - // open-directory affordance renders and the path-text fallback stays a - // component-test concern. - openDocument: (request) => { - const { agentPreset } = request.payload - const existing = fixturePresets.get(agentPreset) - if (existing === undefined || existing.trust === 'system') { - return err(request, { - code: 'agent-preset-read-only', - message: `agent preset "${agentPreset}" ships with the deployment`, - details: { agentPreset, reason: 'it ships with the deployment' }, - }) - } - return ok(request, { opened: true as const }) - }, - }, - - skills: { - list: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - return ok(request, { - skills: [ - { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, - { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, - ], - }) - }, - }, - settings: { - // Native opens are deterministic no-op successes in this fixture, as is host.openPath. - openDocument: request => ok(request, { opened: true as const }), - }, - llm: { - providers: request => ok(request, { - providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, declared: false }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, declared: false }, - // One hand-declared route, so a surface reading this fixture meets - // the tagged shape rather than only the shipped one. - { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true }, - ], - }), - models: request => ok(request, { - default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], - groups: fixtureModelGroups(), - failures: [], - }), - // The fixture endpoint is imaginary, so the interrogation answers the - // catalog it already serves — enough for a surface to exercise adopting - // candidates without a reachable provider. - discoverModels: request => ok(request, { - models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), - }), }, // Satisfies the ApiProxy contract type only: the browser export button // hands GET /api/session.export to the native download manager, so this @@ -3484,6 +3430,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { refs?: readonly string[] value?: string ns?: string + settingsNs?: string agentPreset?: string from?: string id?: string @@ -3538,6 +3485,58 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string)) case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string)) case 'settings/describe': return Promise.resolve(settingsRemotes.describe()) + case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument()) + case 'settings/openAgentPresetDirectory': return Promise.resolve( + settingsRemotes.openAgentPresetDirectory(args.agentPreset as string), + ) + case 'skills/list': { + const skillRequest = request as { readonly sessionId: SessionId } + const missing = requireRemoteSession(skillRequest) + if (missing !== undefined) return missing + return sessionOk({ + skills: [ + { name: 'fixture-demo', description: 'fixture 技能样本', whenToUse: '仅供 UI 目录渲染验收', modelInvocable: true }, + { name: 'fixture-user-only', description: 'fixture 仅用户技能样本', modelInvocable: false }, + ], + }) + } + case 'session/openWorkspacePath': { + const pathRequest = request as { readonly sessionId: SessionId; readonly path: string } + const missing = requireRemoteSession(pathRequest) + return missing ?? sessionOk({ opened: true as const }) + } + case 'session/modelCatalog': return Promise.resolve({ + ok: true, + value: { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], + groups: fixtureModelGroups(), + failures: [], + }, + }) + case 'llm/listProviders': return Promise.resolve({ + ok: true, + value: [ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'openai', name: 'openai' }, + { id: 'acme-gateway', name: 'Acme Gateway' }, + ], + }) + case 'llm/listConfigurableProviders': return Promise.resolve({ + ok: true, + value: [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], declared: false }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], declared: false }, + { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], declared: true }, + ], + }) + // The fixture endpoint is imaginary, so interrogation answers the + // catalog it already serves without a network request. + case 'llm/discoverModels': return Promise.resolve({ + ok: true, + value: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), + }) case 'settings/update': return Promise.resolve(settingsRemotes.update(args.ns as string)) case 'settings/replace': return Promise.resolve(settingsRemotes.replace(args.ns as string)) case 'settings/mutate': return Promise.resolve(settingsRemotes.mutate(args.ns as string)) @@ -3643,13 +3642,13 @@ export class FixtureApiClient extends AbstractApiClient { payload: RequestPayload, signal?: AbortSignal, ): Promise>> { + void signal const request = rpcRequest(payload) const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload } this.onEnvelope(full) const response = await this.dispatch( method, request as RpcRequest, - signal ?? new AbortController().signal, ) as RpcResponse> const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result } this.onEnvelope(fullResponse) @@ -3660,17 +3659,9 @@ export class FixtureApiClient extends AbstractApiClient { private dispatch( method: keyof RpcMethodMap, request: RpcRequest, - signal: AbortSignal, ): Promise> { switch (method) { case 'host.describe': return this.api.host.describe(request) - case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal) - case 'skill.list': return this.api.skills.list(request) - case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) - case 'settings.openDocument': return this.api.settings.openDocument(request, signal) - case 'llm.providers': return this.api.llm.providers(request) - case 'llm.models': return this.api.llm.models(request) - case 'llm.discoverModels': return this.api.llm.discoverModels(request, signal) } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index c8b66fbc77..a24d014496 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -31,14 +31,11 @@ declare module '@deepseek-ai/cordis' { // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { ApiProxy, HostApi, - SkillsApi, SkillEntry, ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, - SettingsApi, - ConfigurableProviderView, DiscoveredModelView, LlmApi, } from './api.ts' export { RpcId, diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index dda3c9d26e..4019655d72 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -65,7 +65,7 @@ export function apply(ctx: ClientContext): void { // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController({ ...api, ...settingsWire }, ctx.remote, () => { + const section = new AgentPresetSectionController(api, ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 099d92b186..70baab32ec 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -15,7 +15,6 @@ */ import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -134,8 +133,8 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: SettingsWireFace & Pick, - private readonly remote: Pick, + private readonly api: Pick, + private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is @@ -296,13 +295,13 @@ export class AgentPresetSectionController { */ async openLocation(id: string): Promise { try { - const response = await this.api.agentPresets.openDocument({ agentPreset: id }) - if (!response.result.ok) { - this.set({ error: response.result.error.message }) + const result = await this.remote.settings.openAgentPresetDirectory(id) + if (!result.ok) { + this.set({ error: result.error.message }) return } - if (response.result.value.opened) return - const { path } = response.result.value + if (result.value.opened) return + const { path } = result.value this.set({ revealedPaths: { ...this.store.getSnapshot().revealedPaths, [id]: path } }) } catch (error) { this.set({ error: messageOf(error) }) @@ -350,7 +349,7 @@ export class AgentPresetSectionController { * @returns once the write settled and the roster was re-read. */ async makeDefault(id: string): Promise { - const failure = await writeDefaultPreset(this.api, id) + const failure = await writeDefaultPreset(this.remote, id) if (failure !== undefined) { this.set({ error: failure }) return diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 10382a63bf..fcafb63b8b 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -74,8 +74,7 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -105,7 +104,6 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 1fc602e1ac..829b36bd0f 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,10 +1,10 @@ /** Register the Chat Conversation target, renderers, stats, and details surface. */ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' // Type-only service and declaration merges used by the apply world. import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -46,7 +46,8 @@ const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ - 'slots', 'sessions', 'uiSession', 'uiConversation', 'uiWorkspace', 'layout', 'locale', 'settingsScope', + 'slots', 'sessions', 'uiSession', 'uiConversation', 'layout', 'locale', + 'settingsScope', 'remote', 'remote.session', ] /** @@ -115,9 +116,9 @@ export function apply(ctx: Context): void { ctx.layout.openDetails() }, fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), - openFile: (path) => { - const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) + openFile: async (path) => { + const result = await ctx.remote.session.openWorkspacePath({ sessionId, path }) + if (!result.ok) throw new Error(`path open failed: ${result.error.message}`) }, loadOlder: () => { void session.loadOlder() }, loadImage: Object.assign( diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 4d42320885..0800260fbb 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../../runtime-diagnostics/invariants" }, - { - "path": "../../util/workspace-path" - }, { "path": "../../session/session-stats" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 0d54a69f01..d14dcd894d 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -33,7 +33,6 @@ "client": { "inject": [ "@deepseek-ai/dsh-api-session-controller", - "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-commands", "@deepseek-ai/dsh-api-remotes" @@ -49,7 +48,6 @@ "peerDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", @@ -64,7 +62,6 @@ "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", diff --git a/packages/client/ui-model-selection/src/client/catalog.ts b/packages/client/ui-model-selection/src/client/catalog.ts index b0ec866a9a..5bfbcb2d1e 100644 --- a/packages/client/ui-model-selection/src/client/catalog.ts +++ b/packages/client/ui-model-selection/src/client/catalog.ts @@ -1,9 +1,6 @@ /** One Host-generation model catalog shared by every Session selector. */ -import { - type IApiClient, - type ModelCatalog, -} from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' /** Observable lifecycle of the shared model catalog. */ @@ -25,8 +22,8 @@ export class ModelCatalogDirectory { private generation = 0 private inflight: Promise | undefined - /** @param api - shared connection API client. */ - constructor(private readonly api: IApiClient) {} + /** @param session - Session Remote namespace carrying the Host-generation catalog. */ + constructor(private readonly session: Pick) {} /** * Return the current generation's catalog, sharing its one in-flight load. @@ -41,14 +38,14 @@ export class ModelCatalogDirectory { draft.status = 'loading' draft.error = null }) - const operation = this.api.llm.models({}).then((response) => { - if (!response.result.ok) { - throw new Error(`${response.result.error.code}: ${response.result.error.message}`) + const operation = this.session.modelCatalog().then((response) => { + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) } if (generation === this.generation) { - this.store.set({ value: response.result.value, status: 'ready', error: null }) + this.store.set({ value: response.value, status: 'ready', error: null }) } - return response.result.value + return response.value }).catch((error: unknown) => { if (generation === this.generation) { this.store.update((draft) => { diff --git a/packages/client/ui-model-selection/src/client/index.ts b/packages/client/ui-model-selection/src/client/index.ts index d68e0463d3..dd7d4a89fd 100644 --- a/packages/client/ui-model-selection/src/client/index.ts +++ b/packages/client/ui-model-selection/src/client/index.ts @@ -2,7 +2,7 @@ * Model selection plugin, browser half — TWO entries over ONE per-session * directory owned by ModelDirectoryResolver (`ctx.modelDirectories`). The /model popupSelect * contribution and the composer's named `conversation.input.model` seat share - * one Host-generation `llm.models` catalog, combine it with the Session's + * one Host-generation `session/modelCatalog` catalog, combine it with the Session's * durable model-selection projection, and submit through `session.selectModel`. * A switch made in either entry is what the other shows next. Failures * ride each entry's own retry surface (popup shell error/retry; seat menu diff --git a/packages/client/ui-model-selection/src/client/service.ts b/packages/client/ui-model-selection/src/client/service.ts index 7e01ecd5a2..2053d70b85 100644 --- a/packages/client/ui-model-selection/src/client/service.ts +++ b/packages/client/ui-model-selection/src/client/service.ts @@ -15,7 +15,6 @@ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-session-controller/client' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { ModelCatalogDirectory } from './catalog.ts' import { ModelDirectory } from './directory.ts' @@ -34,7 +33,7 @@ interface LiveState { /** The `ctx.modelDirectories` session model-selection service. */ export class ModelDirectoryResolver extends Service { - static inject = ['sessions', 'remote', 'remote.session', 'connection'] + static inject = ['sessions', 'remote', 'remote.session'] private readonly live: LiveState = { directories: new Map() } private readonly catalog: ModelCatalogDirectory @@ -49,9 +48,7 @@ export class ModelDirectoryResolver extends Service { constructor(ctx: Context, config: { blockReason: () => string }) { super(ctx, 'modelDirectories') this.blockReason = config.blockReason - const connection = ctx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error('ui-model-selection: connection service is unavailable') - this.catalog = new ModelCatalogDirectory(connection.api) + this.catalog = new ModelCatalogDirectory(ctx.remote.session) void this.catalog.load().catch(() => { /* selectors expose the shared error */ }) ctx.on('connection/reset', () => { this.catalog.resetGeneration() diff --git a/packages/client/ui-settings-general/src/client/index.ts b/packages/client/ui-settings-general/src/client/index.ts index d1342e9b1d..abdf2829b3 100644 --- a/packages/client/ui-settings-general/src/client/index.ts +++ b/packages/client/ui-settings-general/src/client/index.ts @@ -55,7 +55,7 @@ const NS = 'settings' * ui-settings' apply, whose activation order relative to this one is NOT * constrained; registrations depend on their slots through `slots.inject()`. */ -export const inject = ['slots', 'locale', 'connection', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope'] /** * Register the `settings` dictionaries, the chrome content, and the General @@ -72,7 +72,7 @@ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle // The shared SettingsScope mirror updates after document commits and reconnects. const documentController = connection.isLoopback - ? new SettingsDocumentStore(connection.api, ctx.settingsScope.describe()) + ? new SettingsDocumentStore(ctx.remote, ctx.settingsScope.describe()) : undefined const documentInjected = documentController === undefined ? undefined 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 b545ec66f3..4e7fec2ecd 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-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' @@ -32,7 +32,7 @@ export class SettingsDocumentStore { * @param describeFace - the shared mirror's describe face (`hasDocument` source). */ constructor( - private readonly api: Pick, + private readonly remote: Pick, private readonly describeFace: SettingsDescribeFace, ) {} @@ -63,8 +63,8 @@ export class SettingsDocumentStore { state.error = null }) try { - const response = await this.api.settings.openDocument({}) - if (!response.result.ok) throw new Error(response.result.error.message) + const result = await this.remote.settings.openSettingsDocument() + if (!result.ok) throw new Error(result.error.message) } catch (error) { this.store.update((state) => { state.error = messageOf(error) }) } finally { diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 5366374f39..57fa2abcc6 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -47,7 +47,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -55,7 +54,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-api-remotes": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 20276412bb..a1be9f9085 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,11 +16,11 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { DiscoveredModelView, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' -import { messageOf } from './store.ts' +import { messageOf, type ModelsWire } from './store.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -80,7 +80,7 @@ export interface ModelListEditorProps { */ probeBlocked?: keyof typeof en | undefined /** Wire face the fetch action calls. */ - api: Pick + api: Pick /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ @@ -142,7 +142,7 @@ function capacitySpelling(value: number | undefined): string { } /** Adopt a candidate, keeping whatever capacities the provider disclosed. */ -function adopt(candidate: DiscoveredModelView): ModelDraft { +function adopt(candidate: LlmDiscoveredModel): ModelDraft { return { id: candidate.id, ...candidate.name === undefined ? {} : { name: candidate.name }, @@ -160,7 +160,7 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const { models, onChange, probe, api, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) - const [candidates, setCandidates] = useState(undefined) + const [candidates, setCandidates] = useState(undefined) const [picked, setPicked] = useState>(new Set()) // Rows carry an id and a name; capacities are the exception, so they stay // folded until asked for rather than crowding every row with four inputs. @@ -229,18 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await api.llm.discoverModels({ - settingsNs: probe.settingsNs, + const response = await api.llm.discoverModels(probe.settingsNs, { ...probe.provider === undefined ? {} : { provider: probe.provider }, ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL }, ...probe.api === undefined ? {} : { api: probe.api }, ...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey }, }) - if (!response.result.ok) { - setFailure(response.result.error.message) + if (!response.ok) { + setFailure(response.error.message) return } - const found = response.result.value.models + const found = response.value if (found.length === 0) { setFailure(t('fetchEmpty')) return diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index b975c91f1f..c397d795c9 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -7,7 +7,6 @@ * packages/client/AGENTS.md. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the shell's SlotMap merge (the 'settings.section' entry). import type {} from '@deepseek-ai/dsh-client-ui-settings/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -42,7 +41,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Dictionary namespace owned by this plugin. */ const NS = 'settings.models' -export type { ModelsCredentials, ModelsSettingsState, ModelsWire, ProviderRow } from './store.ts' +export type { + ModelsCredentials, ModelsLlm, ModelsSettingsState, ModelsWire, ProviderDirectoryEntry, ProviderRow, +} from './store.ts' /** * Refetch the page snapshot only after its first load: an unopened Models @@ -60,7 +61,7 @@ export function refreshIfLoaded(controller: ModelsSettingsStore): void { * constrained; registration depends on each slot through `slots.inject()`. */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'settingsScope', 'settingsSchema', ] @@ -73,14 +74,11 @@ export const inject = [ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') - const connection = ctx.get('connection') as ConnectionHandle const schema = createSettingsSchemaOperations(ctx.settingsSchema) - // The page's two carriers under one face: model discovery and the catalog - // still ride the unary API, while settings and credentials are Remote - // namespaces. + // Every configuration operation rides its owning Remote namespace. const wire: ModelsWire = { - ...connection.api, credentials: ctx.remote.credentials, + llm: ctx.remote.llm, settings: ctx.remote.settings, } const controller = new ModelsSettingsStore(wire, schema, ctx.settingsScope.describe()) diff --git a/packages/client/ui-settings-models/src/client/slot-contract.ts b/packages/client/ui-settings-models/src/client/slot-contract.ts index b8360e21c9..a9eef19368 100644 --- a/packages/client/ui-settings-models/src/client/slot-contract.ts +++ b/packages/client/ui-settings-models/src/client/slot-contract.ts @@ -4,7 +4,7 @@ * without editing it. * * `settings.models.provider-card` is keyed by the row's owning settings - * namespace (`ConfigurableProviderView.settingsNs`): an adapter family's + * namespace (`ProviderDirectoryEntry.settingsNs`): an adapter family's * companion plugin registers one entry under the family's namespace and * receives every card of that family — shipped, added, and hand-declared rows * alike — while the section never learns what the namespace means. Keying on @@ -17,8 +17,8 @@ * the declaration. The types therefore live with their declarer. */ -import type { ConfigurableProviderView } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-slots' +import type { ProviderDirectoryEntry } from './store.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { @@ -42,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Owner share of one provider-card extension occurrence. */ export interface ProviderCardExtrasOwnerProps { /** The card's directory row (route id, display name, settings address, live state). */ - provider: ConfigurableProviderView + provider: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */ configured: boolean /** Whether the row's referenced api-key credential is confirmed configured (the page's credential join). */ diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 6f664bd068..fe98870321 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -1,13 +1,14 @@ /** * Models settings page store: one snapshot joining the configurable-provider - * directory (`llm.providers`), the settings namespaces (shared settings mirror), + * directory (`llm/listProviders` joined with `llm/listConfigurableProviders`), + * the settings namespaces (shared settings mirror), * and the referenced credentials (`credentials/describe`). The host stays the * single fact source — every mutation writes through the wire and the page * re-renders from the next describe, pushed or refetched. */ import type { - ClientRemote, ConfigurableProviderView, CredentialInfo, IApiClient, SettingsNamespaceView, + ClientRemote, CredentialInfo, LlmConfigurableProvider, LlmProviderInfo, SettingsNamespaceView, } from '@deepseek-ai/dsh-api-remotes/client' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -23,22 +24,71 @@ const PROBE_ROUTE = '\u0000probe' /** The credentials Remote methods the Models page reads and writes through. */ export type ModelsCredentials = Pick +/** LLM Remote methods used by the Models page. */ +export type ModelsLlm = Pick< + ClientRemote['llm'], + 'discoverModels' | 'listConfigurableProviders' | 'listProviders' +> + +/** One provider row after joining the configurable directory with live routes. */ +export interface ProviderDirectoryEntry { + readonly provider: string + readonly displayName: string + readonly settingsNs: string + readonly settingsPath: readonly string[] + readonly active: boolean + readonly declared?: boolean +} + /** - * Every wire face the Models page reaches: the settings and llm unary domains, - * plus the credentials Remote namespace, which is addressed by reference name - * and never answers with a value. + * Join declared configurable providers with the currently registered routes. + * @param registered - live provider routes in registration order. + * @param directory - declared configurable providers in declaration order. + * @returns declared rows followed by live routes with no declaration. */ -export interface ModelsWire extends Pick { +export function joinProviderDirectory( + registered: readonly LlmProviderInfo[], + directory: readonly LlmConfigurableProvider[], +): ProviderDirectoryEntry[] { + const active = new Set(registered.map(provider => provider.id)) + const declared = new Set(directory.map(entry => entry.provider)) + const rows: ProviderDirectoryEntry[] = directory.map(entry => ({ + provider: entry.provider, + displayName: entry.displayName, + settingsNs: entry.settingsNs, + settingsPath: [...entry.settingsPath], + active: active.has(entry.provider), + ...entry.declared === undefined ? {} : { declared: entry.declared }, + })) + for (const provider of registered) { + if (declared.has(provider.id)) continue + rows.push({ + provider: provider.id, + displayName: provider.name, + settingsNs: '', + settingsPath: [], + active: true, + }) + } + return rows +} + +/** + * Every Remote wire face the Models page reaches. + */ +export interface ModelsWire { /** The settings Remote namespace: the redacted read and the profile writes. */ settings: SettingsRemote /** Credential state and writes for the references provider profiles name. */ credentials: ModelsCredentials + /** Provider directory reads and draft endpoint discovery. */ + llm: ModelsLlm } /** One provider row the page renders. */ export interface ProviderRow { /** The directory entry (route id, display name, settings address, live state). */ - entry: ConfigurableProviderView + entry: ProviderDirectoryEntry /** Whether any layer configures this provider (its profile resolves). */ configured: boolean /** Whether the user layer alone carries the profile (removal restores the base). */ @@ -157,20 +207,22 @@ export class ModelsSettingsStore { async load(): Promise { const generation = ++this.generation this.store.update((s) => { s.status = 'loading'; s.error = null }) - let providers: ConfigurableProviderView[] + let providers: ProviderDirectoryEntry[] let writable: boolean let views: readonly SettingsNamespaceView[] try { - const [providersResponse] = await Promise.all([ - this.api.llm.providers({}), + const [registered, declared] = await Promise.all([ + this.api.llm.listProviders(), + this.api.llm.listConfigurableProviders(), this.describeFace.ensure(), ]) - if (!providersResponse.result.ok) throw new Error(providersResponse.result.error.message) + if (!registered.ok) throw new Error(registered.error.message) + if (!declared.ok) throw new Error(declared.error.message) const mirrored = this.describeFace.getSnapshot() if (mirrored.view === undefined) { throw new Error(mirrored.error ?? 'settings are unavailable in this browser') } - providers = providersResponse.result.value.providers + providers = joinProviderDirectory(registered.value, declared.value) writable = mirrored.view.writable views = mirrored.view.namespaces } catch (error) { diff --git a/packages/client/ui-settings-plugins/src/client/index.ts b/packages/client/ui-settings-plugins/src/client/index.ts index 8dd09f1d03..40ec376432 100644 --- a/packages/client/ui-settings-plugins/src/client/index.ts +++ b/packages/client/ui-settings-plugins/src/client/index.ts @@ -9,7 +9,6 @@ * settings scope, which keeps them unaware of one another and of other tabs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the settings shell's SlotMap merge (the 'settings.section' entry) @@ -54,14 +53,15 @@ export type { WebSearchCardFace, WebSearchCardState } from './web-search-card-co const NS = 'settings.plugins' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope'] +export const inject = [ + 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', +] /** * Mount the plugin configuration section and the cards this package ships. * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const { api } = ctx.get('connection') as ConnectionHandle const t = ctx.locale.bind(NS) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-plugins: section dictionaries') @@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void { ctx.settingsScope.bind({ namespace: WEB_SEARCH_NS }), ctx.remote.credentials) const subagentModelSelection = new SubagentModelSelectionCardController( ctx.settingsScope.bind({ namespace: SUBAGENT_MODEL_SELECTION_NS }), - api, + ctx.remote.session, ) // The credential a card reports is not part of any settings section, so its diff --git a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts index 053074a1ff..9e1b5c2d2a 100644 --- a/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts +++ b/packages/client/ui-settings-plugins/src/client/subagent-model-selection-card-controller.ts @@ -1,7 +1,7 @@ /** Staged editor for the Host-owned subagent model allowlist. */ import type { - IApiClient, + ClientRemote, ModelProviderGroup, } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' @@ -145,11 +145,11 @@ export class SubagentModelSelectionCardController { /** * @param scope - bound `subagent-model-selection` settings scope. - * @param api - Host LLM directory face. + * @param session - Host Session model-catalog face. */ constructor( private readonly scope: SettingsScope, - private readonly api: Pick, + private readonly session: Pick, ) { this.store = createSnapshotStore(this.projection()) this.unsubscribe = scope.subscribe(() => { @@ -321,11 +321,11 @@ export class SubagentModelSelectionCardController { this.catalogPartial = false this.publish() try { - const response = await this.api.llm.models({}) + const response = await this.session.modelCatalog() if (generation !== this.catalogGeneration) return - if (!response.result.ok) throw new Error(response.result.error.message) - this.catalogGroups = response.result.value.groups - this.catalogPartial = response.result.value.failures.length > 0 + if (!response.ok) throw new Error(response.error.message) + this.catalogGroups = response.value.groups + this.catalogPartial = response.value.failures.length > 0 this.catalogStatus = 'ready' } catch { if (generation !== this.catalogGeneration) return diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 9b66956c2d..5b6a9bb44e 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -1,6 +1,6 @@ /** * Skill reference plugin, browser half: registers the '/' skill source — - * candidates from the skill.list RPC addressed by the per-call session + * candidates from the `skills/list` Remote addressed by the per-call session * projection's sessionId (sessions are always agent-backed; the host * resolves cwd from the session header). A pick lands the literal `/name ` * text and the prompt ships the same literal (plain-text-reference decision; @@ -10,7 +10,7 @@ * leading `/name` naming a user-invocable skill and injects the rendered * body for every entry point, including `disable-model-invocation` skills the * model-side catalog never lists (issue #1470). The RPC rides the plugin's - * root-context connection captured at registration — the source never reads + * root-context Remote captured at registration — the source never reads * services off a per-call argument. Draft chip visuals derive from * the lexicon scan; this source implements no reference codec. * @@ -31,7 +31,7 @@ */ // Type-only: the carrier types, the forwarded Host-event face and the ctx.remote merge. import type { Context as ClientContext } from '@deepseek-ai/cordis' -import type { ConnectionHandle, SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' +import type { SkillEntry } from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { InputTriggerServiceContract, InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client' @@ -71,7 +71,7 @@ export function apply(ctx: ClientContext): void { SkillRow, )) - const skills = (ctx.get('connection') as ConnectionHandle).api.skills + const skills = ctx.remote.skills const sessions = ctx.sessions // Session-keyed catalog cache; single-flight per key. Plugin-closure state: // the fiber effect below is its teardown boundary. @@ -98,8 +98,8 @@ export function apply(ctx: ClientContext): void { if (existing !== undefined) return existing.promise const abort = new AbortController() const promise = (async () => { - const { result } = await skills.list({ sessionId }, abort.signal) - if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`) + const result = await skills.list({ sessionId }, abort.signal) + if (!result.ok) throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`) return result.value.skills })() const entry: CatalogFetch = { promise, abort } diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 7f87202365..41a9b80fd1 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -75,7 +75,7 @@ export function apply(ctx: Context): void { const workspaces = ctx.get('workspaces') as IWorkspaces const hostDescription = connection.hostDescription const uiWorkspace = new UiWorkspaceService( - ctx, connection.api, ctx.remote.directoryPicker, workspaces, sessions) + ctx, ctx.remote.directoryPicker, workspaces, sessions) ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } }) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-workspace: dictionaries') diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 4d0f3e3fa9..a4e3eb3880 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -1,7 +1,6 @@ /** Workspace archive and directory UI capability. */ import { Service, type Context } from '@deepseek-ai/cordis' -import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' import type { @@ -50,11 +49,6 @@ export interface UiWorkspace { * @returns created absolute path. */ createDirectory(path: string, name: string): Promise - /** - * Open a path with the Host operating system. - * @param path - absolute or Host-resolvable path. - */ - openPath(path: string): Promise } declare module '@deepseek-ai/cordis' { @@ -80,14 +74,12 @@ class UiWorkspaceService extends Service implements UiWorkspace { /** * @param ctx - Client root Context. - * @param api - shared Host API carrier. * @param directoryPicker - the directory-picking Remote namespace. * @param workspaces - pure Workspace Controller. * @param sessions - pure Session Controller. */ constructor( ctx: Context, - private readonly api: IApiClient, private readonly directoryPicker: ClientRemote['directoryPicker'], private readonly workspaces: IWorkspaces, private readonly sessions: ISessions, @@ -163,13 +155,6 @@ class UiWorkspaceService extends Service implements UiWorkspace { return result.value } - async openPath(path: string): Promise { - const response = await this.api.host.openPath({ path }) - if (!response.result.ok) { - throw new Error(`path open failed: ${response.result.error.message}`) - } - } - private watchNavigation(): () => void { let initial: 'waiting' | 'connecting' | 'done' = 'waiting' let disposed = false From ce3391e280e74ef16b9c16286deb55d40bf071aa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:09:27 +0800 Subject: [PATCH 096/130] refactor(apiproxy): retire migrated unary routes --- knip.json | 5 + packages/host/apiproxy/package.json | 9 +- packages/host/apiproxy/src/api-proxy.ts | 335 +----------------- .../apiproxy/src/api/agent-presets.schema.ts | 19 - .../host/apiproxy/src/api/agent-presets.ts | 25 -- packages/host/apiproxy/src/api/host.schema.ts | 10 - packages/host/apiproxy/src/api/host.ts | 10 - packages/host/apiproxy/src/api/index.ts | 12 - packages/host/apiproxy/src/api/llm.schema.ts | 115 ------ packages/host/apiproxy/src/api/llm.ts | 90 ----- packages/host/apiproxy/src/api/rpc-map.ts | 11 - packages/host/apiproxy/src/api/rpc.schema.ts | 1 - packages/host/apiproxy/src/api/rpc.ts | 9 - .../host/apiproxy/src/api/settings.schema.ts | 16 - packages/host/apiproxy/src/api/settings.ts | 22 -- .../host/apiproxy/src/api/skills.schema.ts | 28 -- packages/host/apiproxy/src/api/skills.ts | 33 -- packages/host/apiproxy/src/fetch/client.ts | 58 +-- packages/host/apiproxy/src/fetch/handler.ts | 19 +- packages/host/apiproxy/src/index.ts | 13 +- .../host/apiproxy/src/native-path-opener.ts | 202 ----------- packages/host/apiproxy/tsconfig.json | 21 -- 22 files changed, 14 insertions(+), 1049 deletions(-) delete mode 100644 packages/host/apiproxy/src/api/agent-presets.schema.ts delete mode 100644 packages/host/apiproxy/src/api/agent-presets.ts delete mode 100644 packages/host/apiproxy/src/api/llm.schema.ts delete mode 100644 packages/host/apiproxy/src/api/llm.ts delete mode 100644 packages/host/apiproxy/src/api/settings.schema.ts delete mode 100644 packages/host/apiproxy/src/api/settings.ts delete mode 100644 packages/host/apiproxy/src/api/skills.schema.ts delete mode 100644 packages/host/apiproxy/src/api/skills.ts delete mode 100644 packages/host/apiproxy/src/native-path-opener.ts diff --git a/knip.json b/knip.json index 026215eb4f..b22ef3caaa 100644 --- a/knip.json +++ b/knip.json @@ -415,6 +415,11 @@ "tests/**/*.ts" ] }, + "packages/llm/llm": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/llm/llm-deepseek": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 9942223158..b1eecd9a6b 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -50,16 +50,10 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "fflate": "^0.8.2", @@ -67,14 +61,13 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^" } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 30844d9bbb..29ebd6b732 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -4,19 +4,10 @@ */ import { homedir } from 'node:os' -import { dirname } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import type { ModelSelection } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-agent-presets/types' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { isUserInvocable } from '@deepseek-ai/dsh-skill' -import { - InvalidPresetIdError, PresetExistsError, - PresetNotWritableError, UnknownPresetError, -} from '@deepseek-ai/dsh-agent-presets' -import type { ApiProxy, ConfigurableProviderView } from './api/index.ts' -import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' +import { canOpenNativePath } from '@deepseek-ai/dsh-native-command' +import type { ApiProxy } from './api/index.ts' import { DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, flushLiveSessionLog, @@ -27,77 +18,30 @@ import { type SessionLogCompressionLevel, } from './session-export.ts' import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -// 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' -import type { ScopeKey } from '@deepseek-ai/dsh-scope' -import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts' -import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts' - -/** Read live abort state across awaits without treating it as synchronously immutable. */ -function isAborted(signal: AbortSignal): boolean { - return signal.aborted -} +import type { RpcRequest, RpcResponse } from './api/rpc.ts' /** Wrap an ok result echoing the request's rpcId. */ function ok(request: RpcRequest, value: T): RpcResponse { return { rpcId: request.rpcId, result: { ok: true, value } } } -/** Wrap an error result echoing the request's rpcId. */ -function err(request: RpcRequest, error: RpcError): RpcResponse { - return { rpcId: request.rpcId, result: { ok: false, error } } -} - /** Deployment metadata and Host integrations consumed by the API implementation. */ export interface ApiProxyDefaults { /** Current deployment model selection reported by `host.describe`. */ defaultModelSelection: () => ModelSelection /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */ cwd: string - /** Native open-with-default-application; injectable for carrier tests. */ - openPath?: (path: string, signal: AbortSignal) => Promise - /** Native text-editor handoff; injectable for settings-document tests. */ - openTextFile?: (path: string, signal: AbortSignal) => Promise /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ sessionExportCompressionLevel?: SessionLogCompressionLevel /** * Whether handing a path to the native opener can work at all — the * `hasDocument` capability the preset roster reports, and the switch * between opening a preset directory and answering its path as text. - * Absent, an injected `openPath` counts as openable and everything else - * falls back to platform detection ({@link canOpenNativePath}). + * Absent, platform detection decides ({@link canOpenNativePath}). */ canOpenPath?: () => boolean } -/** The roster is absent: this deployment composes no agent presets at all. */ -function noRoster(agentPreset: string): RpcError { - return { - code: 'agent-preset-not-found', - message: 'this deployment composes no agent presets', - details: { agentPreset, available: [] }, - } -} - -/** Map one authoring/roster failure onto its wire code. */ -function presetError(agentPreset: string, error: unknown): RpcError { - if (error instanceof UnknownPresetError) { - return { - code: 'agent-preset-not-found', - message: error.message, - details: { agentPreset: error.presetId, available: [...error.available] }, - } - } - if (error instanceof PresetNotWritableError) { - return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } } - } - if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) { - return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } } - } - return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} } -} - /** * Implement ApiProxy over a composed host context. * @param ctx - a context with the Host spine mounted. @@ -107,75 +51,10 @@ function presetError(agentPreset: string, error: unknown): RpcError { export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL - /** Resolve a Session's live or standing preset scope without resuming it. */ - async function sessionScopeFor( - sessionId: SessionId, - agentPreset: string | undefined, - ): Promise { - const live = ctx.get('agents')?.get(sessionId) - if (live !== undefined) return live - const presets = ctx.get('agentPresets') - if (presets === undefined) return undefined - try { - return await presets.standingKeyFor(agentPreset) - } catch { - // An unknown or unusable recorded preset falls back to the global registry. - return undefined - } - } - - /** Missing-service report shared by the settings domain (skills-domain stance). */ - function settingsAbsent(): RpcError { - return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {} } - } - - /** Open one Host-resolved target and map native failures onto the wire vocabulary. */ - async function openTarget( - request: RpcRequest, path: string, signal: AbortSignal, - open: (path: string, signal: AbortSignal) => Promise, - ): Promise> { - try { - await open(path, signal) - return ok(request, { opened: true as const }) - } catch (error: unknown) { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'path open was aborted', - details: {}, - }) - } - return err(request, { - code: 'internal', - message: `path open failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) - } - } - - /** Open one Host-resolved path with its default application. */ - function openPath( - request: RpcRequest, path: string, signal: AbortSignal, - ): Promise> { - const open = defaults.openPath - ?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal)) - return openTarget(request, path, signal, open) - } - - /** Open one Host-resolved text document in a native editor. */ - function openTextFile( - request: RpcRequest, path: string, signal: AbortSignal, - ): Promise> { - const open = defaults.openTextFile - ?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal)) - return openTarget(request, path, signal, open) - } - /** Whether this deployment can hand a path to a native opener at all. */ function canOpenPaths(): boolean { if (defaults.canOpenPath !== undefined) return defaults.canOpenPath() - // An injected opener is by definition usable; otherwise ask the platform. - return defaults.openPath !== undefined || canOpenNativePath() + return canOpenNativePath() } return { @@ -198,210 +77,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - async openPath(request, signal) { - return openPath(request, request.payload.path, signal) - }, - }, - - agentPresets: { - // Only the desktop opener remains here: the roster, selection, and - // authoring calls are the AgentPresets service's own Remote namespace. - async openDocument(request, signal) { - const { agentPreset } = request.payload - const presets = ctx.get('agentPresets') - if (presets === undefined) return err(request, noRoster(agentPreset)) - try { - const preset = await presets.resolve(agentPreset) - // Same line as copy/remove draw: the shipped install is not the - // user's to manage, and pointing an editor into it invites edits an - // upgrade will silently overwrite. - if (preset.trust !== 'user') { - throw new PresetNotWritableError(preset.id, 'it ships with the deployment') - } - // The id resolved against the Host's own roots is what selects the - // directory — no browser payload carries a path in either direction - // unless the deployment has no opener to hand it to. - const directory = dirname(preset.path) - if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory }) - return await openPath(request, directory, signal) - } catch (error: unknown) { - return err(request, presetError(agentPreset, error)) - } - }, - }, - - skills: { - // Skill lookup never creates or resumes an agent: the session address - // resolves to a canonical cwd from the host-resident session header, and - // the view scope is the live agent or the preset's standing key. - async list(request) { - const { sessionId } = request.payload - let cwd: string | undefined - let agentPreset: string | undefined - try { - using observation = await ctx.sessionQuery.observeSession(sessionId) - if (observation.projections === undefined) { - throw new Error('skill catalog requires a projected Session observation') - } - cwd = observation.header.cwd - agentPreset = observation.projections.values.agentPreset ?? undefined - } catch (error: unknown) { - if (error instanceof SessionQueryError - && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { - return err(request, { - code: 'session-not-found', - message: `session "${sessionId}" not found`, - details: { sessionId }, - }) - } - return err(request, { - code: 'internal', - message: `session "${sessionId}" could not be inspected: ${String(error)}`, - details: {}, - }) - } - if (cwd === undefined) { - // Every served session records its project at create time; a - // cwd-less header is a pre-project legacy log (not served). - return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} }) - } - // The host registry is layered per scope and serves every session. A - // composition may still realm-mount its own registry instead; that - // instance is invisible to host contexts, so address it through the - // live agent (`agents.get` keeps the no-side-effect stance above). - const live = ctx.agents.get(sessionId) - const presets = ctx.get('agentPresets') - const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills') - // A missing service means no composition mounts dsh-skill, not an - // empty catalog. `ctx.get` also - // keeps this handler independent of the gateway plugin's inject list - // (an undeclared `ctx.skills` property read fails the reflect proxy). - const skillRegistry = scoped ?? ctx.get('skills') - if (skillRegistry === undefined) { - return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} }) - } - // Resolve the live or recorded preset scope so the catalog matches the - // Session composition without resuming its Agent. - const scope = await sessionScopeFor(sessionId, agentPreset) - try { - const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable) - return ok(request, { - skills: skills.map(skill => ({ - name: skill.name, - description: skill.description, - ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }, - modelInvocable: skill.invocation.modelInvocable, - })), - }) - } catch (error: unknown) { - return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} }) - } - }, - }, - - settings: { - async openDocument(request, signal) { - const settings = ctx.get('settings') - if (settings === undefined) return err(request, settingsAbsent()) - if (isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'settings document open was aborted', - details: {}, - }) - } - let path: string | undefined - try { - path = await settings.prepareDocument() - } catch (error: unknown) { - if (isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'settings document preparation was aborted', - details: {}, - }) - } - return err(request, { - code: 'internal', - message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`, - details: {}, - }) - } - if (path === undefined) { - return err(request, { - code: 'internal', - message: 'settings provider has no local document to open', - details: {}, - }) - } - if (isAborted(signal)) { - return err(request, { - code: 'cancelled', - message: 'settings document open was aborted', - details: {}, - }) - } - return openTextFile(request, path, signal) - }, - }, - - llm: { - providers(request) { - const registered = ctx.llm.listProviders() - const active = new Set(registered.map(provider => provider.id)) - const directory = ctx.llm.listConfigurableProviders() - const declared = new Set(directory.map(entry => entry.provider)) - const views: ConfigurableProviderView[] = directory.map(entry => ({ - provider: entry.provider, - displayName: entry.displayName, - settingsNs: entry.settingsNs, - settingsPath: [...entry.settingsPath], - active: active.has(entry.provider), - ...entry.declared === undefined ? {} : { declared: entry.declared }, - })) - // Routes registered without a directory declaration still appear — - // they exist and serve models — just with no settings address. No - // adapter claimed them, so nothing can say whether they are shipped. - for (const provider of registered) { - if (declared.has(provider.id)) continue - views.push({ - provider: provider.id, - displayName: provider.name, - settingsNs: '', - settingsPath: [], - active: true, - }) - } - return Promise.resolve(ok(request, { providers: views })) - }, - - async models(request) { - return ok(request, await buildModelCatalog(ctx, defaults.defaultModelSelection())) - }, - - async discoverModels(request, signal) { - const { settingsNs, provider, baseURL, api, apiKey } = request.payload - try { - const models = await ctx.llm.discoverModels(settingsNs, { - ...provider === undefined ? {} : { provider }, - ...baseURL === undefined ? {} : { baseURL }, - ...api === undefined ? {} : { api }, - ...apiKey === undefined ? {} : { apiKey }, - ...signal === undefined ? {} : { signal }, - }) - return ok(request, { models }) - } catch (error: unknown) { - // Every failure here is the user's next move, not a transport fault: - // a wrong endpoint, a rejected key, or a protocol with no listing all - // end at the same place — fill the models in by hand. The details - // repeat only what the caller already sent, never the credential. - return err(request, { - code: 'model-discovery-failed', - message: error instanceof Error ? error.message : String(error), - details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } }, - }) - } - }, }, downloads: { diff --git a/packages/host/apiproxy/src/api/agent-presets.schema.ts b/packages/host/apiproxy/src/api/agent-presets.schema.ts deleted file mode 100644 index ad15512ae0..0000000000 --- a/packages/host/apiproxy/src/api/agent-presets.schema.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * agent-presets domain zod schemas (names derived from map keys: - * agentPresetOpenDocumentRequestSchema / agentPresetOpenDocumentValueSchema). - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' - -/** agentPreset.openDocument request payload. */ -export const agentPresetOpenDocumentRequestSchema = z.object({ - agentPreset: z.string().min(1), -}) satisfies z.ZodType>> - -/** agentPreset.openDocument response value. */ -export const agentPresetOpenDocumentValueSchema = z.union([ - z.object({ opened: z.literal(true) }), - z.object({ opened: z.literal(false), path: z.string() }), -]) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/agent-presets.ts b/packages/host/apiproxy/src/api/agent-presets.ts deleted file mode 100644 index a7749ab6aa..0000000000 --- a/packages/host/apiproxy/src/api/agent-presets.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * agent-presets domain contract: handing one preset's directory to the - * platform opener, which is the only agent-preset call still carried here. - * - * The roster and its authoring calls are the AgentPresets service's own Remote - * namespace. This one stays because the opener is a Host desktop integration - * rather than a preset operation. - */ - -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */ -export interface AgentPresetsApi { - /** - * Hand one locally authored preset's DIRECTORY to the platform opener, for - * editing the files, which are the only composition editor. The request - * carries an id, never a path — the Host resolves it — so no browser - * payload can select an arbitrary filesystem target. Where the deployment - * has no native opener (`canOpenPath: false` on `host.describe`), the reply - * carries the resolved directory for the surface to show as text instead. - * Shipped presets are refused: their install is not the user's to manage. - */ - openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal): - Promise> -} diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 6735e55639..5429b8cab0 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -19,13 +19,3 @@ export const hostDescribeValueSchema = z.object({ home: z.string(), canOpenPath: z.boolean(), }) satisfies z.ZodType>> - -/** host.openPath request payload. */ -export const hostOpenPathRequestSchema = z.object({ - path: z.string().min(1), -}) satisfies z.ZodType>> - -/** host.openPath response value. */ -export const hostOpenPathValueSchema = z.object({ - opened: z.literal(true), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index ceb7998d2b..b256afbc00 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -27,14 +27,4 @@ export interface HostApi { canOpenPath: boolean }>> - /** - * Open a filesystem path with the operating system's default application - * (Finder / Explorer / xdg-open hand-off). The browser carrier's - * prefix-wide trust and authentication checks cover this method like every - * other `/api` request. - */ - openPath( - request: RpcRequest<{ path: string }>, - signal: AbortSignal, - ): Promise> } diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 8e33371141..7f6e5e8018 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -5,19 +5,11 @@ */ import type { HostApi } from './host.ts' -import type { AgentPresetsApi } from './agent-presets.ts' -import type { SkillsApi } from './skills.ts' -import type { SettingsApi } from './settings.ts' -import type { LlmApi } from './llm.ts' import type { DownloadsApi } from './downloads.ts' /** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { host: HostApi - skills: SkillsApi - agentPresets: AgentPresetsApi - settings: SettingsApi - llm: LlmApi /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ downloads: DownloadsApi } @@ -28,10 +20,6 @@ export type { ModelReasoningEffort, ModelSelection, } from '@deepseek-ai/dsh-api-session-controller/types' export type { HostApi } from './host.ts' -export type { SkillsApi, SkillEntry } from './skills.ts' -export type { AgentPresetsApi } from './agent-presets.ts' -export type { SettingsApi } from './settings.ts' -export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' export type { DownloadsApi } from './downloads.ts' // ---- Message layer: narrow forms (domain-signature view) ---- diff --git a/packages/host/apiproxy/src/api/llm.schema.ts b/packages/host/apiproxy/src/api/llm.schema.ts deleted file mode 100644 index 2c1619c8df..0000000000 --- a/packages/host/apiproxy/src/api/llm.schema.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * llm domain zod schemas (names derived from map keys: llmProvidersRequestSchema / - * llmProvidersValueSchema / llmModelsRequestSchema / llmModelsValueSchema). - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts' -import type { - ModelCatalogFailure, - ModelCatalogModel, - ModelSelection, - ModelProviderGroup, - ModelReasoning, - ModelReasoningEffort, -} from '@deepseek-ai/dsh-api-session-controller/types' - -/** One adapter-owned reasoning effort. */ -const modelReasoningEffortSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), -}) satisfies z.ZodType> - -/** Exact-model reasoning metadata. */ -const modelReasoningSchema = z.object({ - efforts: z.array(modelReasoningEffortSchema).min(1), - defaultEffort: z.string().min(1).optional(), -}) satisfies z.ZodType> - -/** One advisory model entry inside a provider group. */ -const modelCatalogModelSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - reasoning: modelReasoningSchema.optional(), -}) satisfies z.ZodType> - -/** One successfully loaded provider group. */ -const modelProviderGroupSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - models: z.array(modelCatalogModelSchema), -}) satisfies z.ZodType> - -/** One provider-local catalog failure. */ -const modelCatalogFailureSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1), - message: z.string(), -}) satisfies z.ZodType> - -/** Complete model selection used as the Host default. */ -const modelSelectionSchema = z.object({ - provider: z.string().min(1), - model: z.string().min(1), - reasoningEffort: z.string().min(1).optional(), -}) satisfies z.ZodType> - -/** ConfigurableProviderView row of llm.providers. */ -export const configurableProviderViewSchema = z.object({ - provider: z.string().min(1), - displayName: z.string().min(1), - settingsNs: z.string(), - settingsPath: z.array(z.string()), - active: z.boolean(), - declared: z.boolean().optional(), -}) satisfies z.ZodType> - -/** llm.providers request payload. */ -export const llmProvidersRequestSchema = z.object({}) satisfies z.ZodType>> - -/** llm.providers response value. */ -export const llmProvidersValueSchema = z.object({ - providers: z.array(configurableProviderViewSchema), -}) satisfies z.ZodType>> - -/** llm.models request payload. */ -export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType>> - -/** llm.models response value. */ -export const llmModelsValueSchema = z.object({ - default: modelSelectionSchema, - routableProviders: z.array(z.string().min(1)), - groups: z.array(modelProviderGroupSchema), - failures: z.array(modelCatalogFailureSchema), -}) satisfies z.ZodType>> - -/** DiscoveredModelView row of llm.discoverModels. */ -export const discoveredModelViewSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1).optional(), - contextWindow: z.number().int().positive().optional(), - maxTokens: z.number().int().positive().optional(), -}) satisfies z.ZodType> - -/** llm.discoverModels request payload. */ -export const llmDiscoverModelsRequestSchema = z.object({ - settingsNs: z.string().min(1), - provider: z.string().min(1).optional(), - baseURL: z.string().min(1).optional(), - api: z.string().min(1).optional(), - // Write-only at the host: used for this one interrogation, never stored and - // never returned. It does ride the client's outgoing envelope like every - // other secret-bearing payload (`settings/update`), which - // `subscribeEnvelopes()` observers can see — redacting that tap is a - // configuration-plane-wide change, not this method's to make alone. - apiKey: z.string().min(1).optional(), -}) satisfies z.ZodType>> - -/** llm.discoverModels response value. */ -export const llmDiscoverModelsValueSchema = z.object({ - models: z.array(discoveredModelViewSchema), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/llm.ts b/packages/host/apiproxy/src/api/llm.ts deleted file mode 100644 index 96b5db874e..0000000000 --- a/packages/host/apiproxy/src/api/llm.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * llm domain contract: host-scoped provider topology for configuration - * surfaces. `llm.providers` merges the configurable-provider directory - * (which providers CAN be configured, and where their settings live) with the - * live route registry; `llm.models` is the session-independent model catalog. - * Clients invalidate from the forwarded `llm/adapters-updated` and - * `settings/document-updated` owner events. - */ - -import type { RpcRequest, RpcResponse } from './rpc.ts' -import type { - ModelCatalog, -} from '@deepseek-ai/dsh-api-session-controller/types' - -/** Wire view of one configurable provider. */ -export interface ConfigurableProviderView { - /** Provider route key (`deepseek-official`, `openai`, …). */ - provider: string - /** Human-readable name for configuration surfaces. */ - displayName: string - /** Settings namespace whose section configures this provider. */ - settingsNs: string - /** Path from that section's root to the provider's profile object (empty = whole section). */ - settingsPath: string[] - /** Whether the route is currently registered (its models are requestable). */ - active: boolean - /** - * Whether the owning adapter knows this route only because configuration - * declared it. Absent when the adapter draws no such distinction, so a - * surface must treat absence as "unknown", not as "shipped". - */ - declared?: boolean -} - -/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */ -export interface LlmApi { - /** - * List every configurable provider with its live/dormant state, in - * directory declaration order. Routes registered outside the directory - * (an adapter that never declared configurability) are appended with their - * registration identity and no settings address. - */ - providers(request: RpcRequest<{}>): Promise> - - /** - * Host-scoped model catalog over every registered provider route: the - * settings surface's models view, needing no session. Per-provider listing - * failures ride `failures` without failing the sound groups. - */ - models(request: RpcRequest<{}>): Promise> - - /** - * Interrogate a provider endpoint the configuration surface is still - * drafting, and return the models it advertises for the user to adopt. - * - * The payload is the draft, not a stored route: `settingsNs` selects the - * adapter family that answers, and the rest comes from the form. `provider` - * names the route being edited when there is one — an adapter that already - * describes that route answers from its own registry, with better metadata - * and no network call, and needs no endpoint. A route it does not describe is - * asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for. - * - * Nothing is written — the reply is candidates, and only a later - * `settings.mutate` decides what a route serves. `apiKey` is accepted here - * but never stored or returned; a provider whose key is already stored omits - * it and the endpoint answers unauthenticated or refuses. - */ - discoverModels( - request: RpcRequest<{ - settingsNs: string - provider?: string - baseURL?: string - api?: string - apiKey?: string - }>, - signal?: AbortSignal, - ): Promise> -} - -/** Wire view of one model an interrogated endpoint advertises. */ -export interface DiscoveredModelView { - /** Model id the endpoint accepts. */ - id: string - /** Human-readable name when the endpoint supplies one. */ - name?: string - /** Maximum combined request and response context, when disclosed. */ - contextWindow?: number - /** Maximum output tokens, when disclosed. */ - maxTokens?: number -} diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 99c8c7e580..e3e67a9c16 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -4,10 +4,6 @@ */ import type { HostApi } from './host.ts' -import type { AgentPresetsApi } from './agent-presets.ts' -import type { SkillsApi } from './skills.ts' -import type { SettingsApi } from './settings.ts' -import type { LlmApi } from './llm.ts' import type { RpcResponse } from './rpc.ts' /** @@ -17,13 +13,6 @@ import type { RpcResponse } from './rpc.ts' */ export interface RpcMethodMap { 'host.describe': HostApi['describe'] - 'host.openPath': HostApi['openPath'] - 'skill.list': SkillsApi['list'] - 'agentPreset.openDocument': AgentPresetsApi['openDocument'] - 'settings.openDocument': SettingsApi['openDocument'] - 'llm.providers': LlmApi['providers'] - 'llm.models': LlmApi['models'] - 'llm.discoverModels': LlmApi['discoverModels'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 434bbb24dc..c8a322fced 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -41,7 +41,6 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index 0981944824..d3d8643301 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -36,15 +36,6 @@ export interface RpcErrorDetailsMap { 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } - /** - * Interrogating a draft provider endpoint did not produce a model listing: - * no adapter family serves the namespace, the protocol has no listing this - * build can read, or the endpoint was unreachable, refused the credential, - * or answered with something else. The message is the adapter's own text — - * it is what the form shows before falling back to hand-entry — and the - * details name the endpoint asked, never the credential offered. - */ - 'model-discovery-failed': { settingsNs: string; baseURL?: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/settings.schema.ts b/packages/host/apiproxy/src/api/settings.schema.ts deleted file mode 100644 index fcfcb75cea..0000000000 --- a/packages/host/apiproxy/src/api/settings.schema.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * settings domain zod schemas (names derived from map keys: - * settingsOpenDocumentRequestSchema / settingsOpenDocumentValueSchema). - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' - -/** settings.openDocument request payload. */ -export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType>> - -/** settings.openDocument response value. */ -export const settingsOpenDocumentValueSchema = z.object({ - opened: z.literal(true), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/settings.ts b/packages/host/apiproxy/src/api/settings.ts deleted file mode 100644 index 07fc0e72be..0000000000 --- a/packages/host/apiproxy/src/api/settings.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * settings domain contract: what remains of the web face of the user-settings - * seam (`ctx.settings`) once the redacted read and the path-addressed write - * moved to the `settings` Remote namespace. Only the local-document handoff - * stays here, because opening a Host file is a platform action rather than a - * settings read. - */ - -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */ -export interface SettingsApi { - /** - * Materialize the configured local document when absent and ask the Host to - * hand it to the platform text-document opener. macOS forces a text editor; - * Linux and Windows use the desktop file association. The request carries - * no path, so the browser cannot choose an arbitrary Host filesystem target. - */ - openDocument( - request: RpcRequest<{}>, signal: AbortSignal, - ): Promise> -} diff --git a/packages/host/apiproxy/src/api/skills.schema.ts b/packages/host/apiproxy/src/api/skills.schema.ts deleted file mode 100644 index a0a54b6e9f..0000000000 --- a/packages/host/apiproxy/src/api/skills.schema.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * skills domain zod schemas (names derived from map keys: skillListRequestSchema / - * skillListValueSchema). - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' -import { sessionIdSchema } from './ids.schema.ts' -import type { SkillEntry } from './skills.ts' - -/** SkillEntry row of skill.list. */ -export const skillEntrySchema = z.object({ - name: z.string().min(1), - description: z.string(), - whenToUse: z.string().optional(), - modelInvocable: z.boolean(), -}) satisfies z.ZodType> - -/** skill.list request payload. */ -export const skillListRequestSchema = z.object({ - sessionId: sessionIdSchema, -}) satisfies z.ZodType>> - -/** skill.list response value. */ -export const skillListValueSchema = z.object({ - skills: z.array(skillEntrySchema), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/skills.ts b/packages/host/apiproxy/src/api/skills.ts deleted file mode 100644 index 61744f05c9..0000000000 --- a/packages/host/apiproxy/src/api/skills.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * skills domain contract: read-only skill catalog lookup addressed by session. - * The session's header cwd resolves to the canonical project root host-side — - * the client never submits a raw path, and skill lookup never creates or - * resumes an Agent. - */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */ -export interface SkillEntry { - /** Kebab-case identifier the user references as `/name` in the composer. */ - readonly name: string - /** Short routing description. */ - readonly description: string - /** Optional extra routing guidance. */ - readonly whenToUse?: string - /** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */ - readonly modelInvocable: boolean -} - -/** - * Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing - * is the domain's only RPC: invocation uses Session Controller's ordinary - * prompt Remote. The host recognizes its leading `/name` token at the pre-step - * boundary (`dsh-tool-skill` injects the rendered body there), so every client - * shares one deterministic path with no dedicated invocation method. - */ -export interface SkillsApi { - /** Lists the user-invocable skill catalog for the session's project. */ - list(request: RpcRequest<{ sessionId: SessionId }>): Promise> -} diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index c33313ece2..4c49240d24 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -12,17 +12,7 @@ import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { serverResponseSchema } from '../api/rpc.schema.ts' -import { - hostDescribeValueSchema, hostOpenPathValueSchema, -} from '../api/host.schema.ts' -import { skillListValueSchema } from '../api/skills.schema.ts' -import { - agentPresetOpenDocumentValueSchema, -} from '../api/agent-presets.schema.ts' -import { - settingsOpenDocumentValueSchema, -} from '../api/settings.schema.ts' -import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts' +import { hostDescribeValueSchema } from '../api/host.schema.ts' /** * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary @@ -39,21 +29,6 @@ import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSc export interface IApiClient { host: { describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> - openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise>> - } - skills: { - list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise>> - } - agentPresets: { - openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise>> - } - settings: { - openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise>> - } - llm: { - providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise>> - models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise>> - discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise>> } } @@ -63,13 +38,6 @@ export interface IApiClient { */ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { 'host.describe': hostDescribeValueSchema, - 'host.openPath': hostOpenPathValueSchema, - 'skill.list': skillListValueSchema, - 'agentPreset.openDocument': agentPresetOpenDocumentValueSchema, - 'settings.openDocument': settingsOpenDocumentValueSchema, - 'llm.providers': llmProvidersValueSchema, - 'llm.models': llmModelsValueSchema, - 'llm.discoverModels': llmDiscoverModelsValueSchema, } /** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */ @@ -195,30 +163,6 @@ export abstract class AbstractApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload, signal) => this.callUnary('host.describe', payload, signal), - openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal), - } - - readonly skills: IApiClient['skills'] = { - list: (payload, signal) => this.callUnary('skill.list', payload, signal), - } - - // Annotated like every sibling, and load-bearing rather than cosmetic: - // inferring this member inlines `AgentPresetEntry` into the emitted - // declaration by the specifier TS picks — the host `index.ts` — which drags - // the whole gateway, and with it the host `Context` merges, into every - // Client program that imports this carrier. - readonly agentPresets: IApiClient['agentPresets'] = { - openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal), - } - - readonly settings: IApiClient['settings'] = { - openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal), - } - - readonly llm: IApiClient['llm'] = { - providers: (payload, signal) => this.callUnary('llm.providers', payload, signal), - models: (payload, signal) => this.callUnary('llm.models', payload, signal), - discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal), } } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index d5fb1d0e29..82142ef923 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -14,17 +14,7 @@ import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } import { RpcId } from '../api/rpc.ts' import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema } from '../api/rpc.schema.ts' -import { - hostDescribeRequestSchema, hostOpenPathRequestSchema, -} from '../api/host.schema.ts' -import { skillListRequestSchema } from '../api/skills.schema.ts' -import { - agentPresetOpenDocumentRequestSchema, -} from '../api/agent-presets.schema.ts' -import { - settingsOpenDocumentRequestSchema, -} from '../api/settings.schema.ts' -import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts' +import { hostDescribeRequestSchema } from '../api/host.schema.ts' /** * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a @@ -44,13 +34,6 @@ type UnaryRoutes = { const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, - 'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) }, - 'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) }, - 'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) }, - 'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) }, - 'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) }, - 'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) }, - 'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index e8816f99ce..474ad6beef 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -14,8 +14,6 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' -import type {} from '@deepseek-ai/dsh-api-session-controller' -import type {} from '@deepseek-ai/dsh-host-directory-picker' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' import { @@ -63,8 +61,7 @@ export interface Config { */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionQuery', - 'sessionController', + 'agentDefaultModel', 'agents', 'attachments', 'sessions', 'sessionQuery', ] static Config: z = z.object({ @@ -74,10 +71,6 @@ export class ApiProxyService extends Service implements ApiProxy { }) readonly host: ApiProxy['host'] - readonly skills: ApiProxy['skills'] - readonly agentPresets: ApiProxy['agentPresets'] - readonly settings: ApiProxy['settings'] - readonly llm: ApiProxy['llm'] readonly downloads: ApiProxy['downloads'] constructor(ctx: Context, config: Config) { @@ -91,10 +84,6 @@ export class ApiProxyService extends Service implements ApiProxy { : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), }) this.host = api.host - this.skills = api.skills - this.agentPresets = api.agentPresets - this.settings = api.settings - this.llm = api.llm this.downloads = api.downloads } } diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts deleted file mode 100644 index f8a065c8e2..0000000000 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * Cross-platform native path and text-document openers used by the local GUI - * carrier. - * - * The default intent prefers the default browser for documents it renders when - * the platform can name one, then falls back to the default application. WSL - * translates every path for the Windows desktop instead of assuming a Linux - * GUI. The text-editor intent never consults the browser. - */ - -import { release as osRelease } from 'node:os' -import { extname } from 'node:path' -import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' - -/** Testable command boundary; native implementations never invoke a shell. */ -export type PathOpenerRunner = NativeCommandRunner - -/** Injectable platform facts for deterministic adapter tests. */ -export interface PathOpenerInternals { - platform?: NodeJS.Platform - /** Kernel release override used to distinguish WSL from desktop Linux. */ - osRelease?: string - /** Environment used for WSL markers and the desktop Linux browser convention. */ - env?: NodeJS.ProcessEnv - run?: PathOpenerRunner -} - -/** Documents a browser renders, as opposed to ones an editor merely edits. */ -const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg']) - -/** - * The macOS bundle registered for `https` — the default browser, as - * LaunchServices records it. The nested version dict is stripped first - * because it carries its own `LSHandlerRoleAll`. - */ -function macBundleForHttps(plist: string): string | undefined { - const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '') - const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0] - if (block === undefined) return undefined - return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1] -} - -/** - * Open one browser-renderable document with the default browser. - * @returns true when a browser took it; false when this platform cannot name - * one, or naming it failed — the caller then uses the default application. - */ -async function openInBrowser( - path: string, signal: AbortSignal, platform: NodeJS.Platform, - run: PathOpenerRunner, env: NodeJS.ProcessEnv, -): Promise { - if (platform === 'darwin') { - let bundle: string | undefined - try { - const { stdout } = await run( - 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal) - bundle = macBundleForHttps(stdout) - } catch { - // No LaunchServices record (a fresh account never changed a default): - // the content-type handler is then the system's own choice anyway. - return false - } - if (bundle === undefined) return false - await run('open', ['-b', bundle, path], signal) - return true - } - if (platform === 'linux') { - // $BROWSER is the portable convention; desktop-entry resolution through - // xdg-settings needs a launcher this package has no business shipping. - const browser = env.BROWSER - if (browser === undefined || browser === '') return false - await run(browser, [path], signal) - return true - } - // Windows names no browser without reading the UserChoice registry, and its - // .html association is the browser in the ordinary case. - return false -} - -/** Native path-open intent; macOS distinguishes text editing from file association. */ -type PathOpenIntent = 'default' | 'text-editor' - -/** PowerShell single-quoted literal (doubles embedded quotes). */ -function powershellLiteral(path: string): string { - return `'${path.replace(/'/g, "''")}'` -} - -/** Whether one environment marker is set to a non-empty value. */ -function present(value: string | undefined): boolean { - return value !== undefined && value !== '' -} - -/** Distinguish WSL from desktop Linux using its process and kernel markers. */ -function isWsl(internals: PathOpenerInternals): boolean { - const env = internals.env ?? process.env - if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true - return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft') -} - -/** Open one Windows-resolvable path through its registered desktop application. */ -async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - await run('powershell.exe', [ - '-NoProfile', - '-Command', - `Invoke-Item -LiteralPath ${powershellLiteral(path)}`, - ], signal) -} - -/** Translate a WSL path before handing it to the Windows desktop. */ -async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise { - const translated = await run('wslpath', ['-w', path], signal) - signal.throwIfAborted() - const windowsPath = translated.stdout.replace(/[\r\n]+$/, '') - if (windowsPath === '') throw new Error('wslpath returned no Windows path') - await openWindowsPath(windowsPath, signal, run) -} - -/** Dispatch one shell-free platform command for the requested open intent. */ -async function openNativePathWithIntent( - path: string, - signal: AbortSignal, - intent: PathOpenIntent, - internals: PathOpenerInternals = {}, -): Promise { - const platform = internals.platform ?? process.platform - const run = internals.run ?? runNativeCommand - const env = internals.env ?? process.env - const wsl = platform === 'linux' && isWsl(internals) - - if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase()) - && await openInBrowser(path, signal, platform, run, env)) return - - if (platform === 'darwin') { - await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal) - return - } - - if (platform === 'win32') { - await openWindowsPath(path, signal, run) - return - } - - if (platform === 'linux') { - if (wsl) { - await openWslPath(path, signal, run) - return - } - await run('xdg-open', [path], signal) - return - } - - throw new Error(`native path opener is unsupported on ${platform}`) -} - -/** - * Whether {@link openNativePath} plausibly reaches a desktop on this host. - * - * macOS and Windows always carry a desktop opener; Linux does when it is WSL - * (the Windows desktop takes the path) or a display server is announced. - * A headless or containerised Linux host answers false, which is what lets a - * surface show a path as text instead of offering a button that would spawn - * `xdg-open` into nothing. - * @param internals - platform and environment seam for deterministic tests. - * @returns true when handing a path to the native opener can work at all. - */ -export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean { - const platform = internals.platform ?? process.platform - if (platform === 'darwin' || platform === 'win32') return true - if (platform !== 'linux') return false - const env = internals.env ?? process.env - return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY) -} - -/** - * Open a filesystem path with the operating system's default application, or - * with the default browser when the path names a document a browser renders. - * @param path - absolute or host-resolvable path (caller owns resolution). - * @param signal - caller/connection lifetime; abort terminates the native command. - * @param internals - Platform, environment, and runner hooks for deterministic tests. - */ -export function openNativePath( - path: string, - signal: AbortSignal, - internals: PathOpenerInternals = {}, -): Promise { - return openNativePathWithIntent(path, signal, 'default', internals) -} - -/** - * Open a text document for editing; macOS bypasses the file-type association - * so a YAML association with a browser cannot consume the gesture. - * @param path - absolute or host-resolvable text-document path. - * @param signal - caller/connection lifetime; abort terminates the native command. - * @param internals - Platform and runner hooks for deterministic tests. - */ -export function openNativeTextFile( - path: string, - signal: AbortSignal, - internals: PathOpenerInternals = {}, -): Promise { - return openNativePathWithIntent(path, signal, 'text-editor', internals) -} diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 1e1d3e37df..048fffcc72 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -8,9 +8,6 @@ "src" ], "references": [ - { - "path": "../../settings/settings" - }, { "path": "../../credentials/credentials" }, @@ -29,39 +26,21 @@ { "path": "../../attachment/attachment" }, - { - "path": "../../llm/llm" - }, { "path": "../../core/agent" }, { "path": "../../core/agent-default-model" }, - { - "path": "../../preset/agent-presets" - }, { "path": "../../core/session" }, - { - "path": "../../core/scope" - }, { "path": "../../session/session-persistence" }, { "path": "../../session-query/session-query" }, - { - "path": "../../skill/skill" - }, - { - "path": "../../interaction/commands" - }, - { - "path": "../directory-picker" - }, { "path": "../../runtime-diagnostics/invariants" }, From 160706be6092b83a70673eba20b63b1fd6778cdc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:56:10 +0800 Subject: [PATCH 097/130] test(api): refresh Remote migration artifacts --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...eb-command-surfaces-and-assembly.i18n.yaml | 4 +- ...07-25-web-command-surfaces-and-assembly.md | 2 +- ...25-web-command-surfaces-and-assembly.zh.md | 2 +- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 6 +- .../2026-07-30-web-config-plane.zh.md | 6 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 6 + ...6-08-10-unary-apiproxy-remote-migration.md | 59 +++ ...8-10-unary-apiproxy-remote-migration.zh.md | 59 +++ ...6-08-17-settings-describe-mirror.i18n.yaml | 4 +- .../2026-08-17-settings-describe-mirror.md | 2 +- .../2026-08-17-settings-describe-mirror.zh.md | 2 +- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 4 +- ...-session-history-and-event-transport.zh.md | 4 +- ...nd-projection-owned-client-state.i18n.yaml | 4 +- ...tions-and-projection-owned-client-state.md | 2 +- ...ns-and-projection-owned-client-state.zh.md | 2 +- ...-onboarding-reads-every-provider.i18n.yaml | 4 +- ...6-08-12-onboarding-reads-every-provider.md | 2 +- ...8-12-onboarding-reads-every-provider.zh.md | 2 +- ...08-18-tool-row-file-open-failure.i18n.yaml | 4 +- .../2026-08-18-tool-row-file-open-failure.md | 6 +- ...026-08-18-tool-row-file-open-failure.zh.md | 6 +- ...26-07-28-skill-invocation-policy.i18n.yaml | 4 +- .../2026-07-28-skill-invocation-policy.md | 2 +- .../2026-07-28-skill-invocation-policy.zh.md | 2 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 6 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 6 +- ...seek-onboarding-credential-setup.i18n.yaml | 4 +- ...30-deepseek-onboarding-credential-setup.md | 2 +- ...deepseek-onboarding-credential-setup.zh.md | 2 +- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 4 +- .../2026-07-31-web-workspace-file-links.zh.md | 4 +- ...8-user-explicit-skill-invocation.i18n.yaml | 4 +- ...26-08-08-user-explicit-skill-invocation.md | 4 +- ...08-08-user-explicit-skill-invocation.zh.md | 4 +- ...authorized-subagent-model-routes.i18n.yaml | 4 +- ...4-user-authorized-subagent-model-routes.md | 2 +- ...ser-authorized-subagent-model-routes.zh.md | 2 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 4 +- .../2026-08-08-copy-only-preset-authoring.md | 4 +- ...026-08-08-copy-only-preset-authoring.zh.md | 4 +- ...-unary-apiproxy-remote-migration.i18n.yaml | 6 - ...6-08-10-unary-apiproxy-remote-migration.md | 120 ------ ...8-10-unary-apiproxy-remote-migration.zh.md | 120 ------ .../tests/agent-preset-authoring.overlay.yml | 3 + apps/web/tests/navigation-panes.e2e.ts | 7 +- apps/web/tests/preview-boot.e2e.ts | 17 +- apps/web/tests/produced-files.e2e.ts | 11 +- apps/web/tests/produced-files.overlay.yml | 3 + apps/web/tests/scaffold-hermetic.e2e.ts | 2 +- apps/web/tests/seeded-history.e2e.ts | 19 +- apps/web/tests/settings-chrome.e2e.ts | 8 +- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 12 +- docs/capability-seams.zh.md | 12 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 21 +- docs/config-catalog.zh.md | 21 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +- docs/event-producer-consumer.zh.md | 12 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 207 +++++----- docs/module-graph.zh.md | 207 +++++----- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 23 +- docs/subsystems/llm-streaming.zh.md | 23 +- docs/subsystems/session-reference.i18n.yaml | 4 +- docs/subsystems/session-reference.md | 31 +- docs/subsystems/session-reference.zh.md | 31 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 21 + docs/subsystems/session.zh.md | 21 + docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 21 + docs/subsystems/settings.zh.md | 21 + docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 23 ++ docs/subsystems/skills.zh.md | 23 ++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 5 +- packages/api/session-controller/README.zh.md | 5 +- .../tests/fake-api.client.ts | 53 +-- .../tests/file-references.host.spec.ts | 20 + .../session-open-workspace-path.host.spec.ts | 95 +++++ .../tests/session-skills.host.spec.ts | 191 +++++++++ .../session-controller/tests/test-remote.ts | 21 +- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 19 +- packages/api/settings-controller/README.zh.md | 19 +- .../tests/settings-controller.host.spec.ts | 109 ++++- .../connection/tests/fake-api.client.ts | 35 +- .../tests/fixture-commands.client.spec.ts | 40 +- .../connection/tests/fixture.client.spec.ts | 12 +- .../connection/tests/node-half.host.spec.ts | 18 +- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../tests/apply.client.spec.ts | 12 +- .../tests/section-store.client.spec.ts | 63 ++- .../tests/apply-inject.client.spec.tsx | 20 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 6 +- .../tests/browser-plugin.client.spec.ts | 34 +- .../tests/catalog.client.spec.ts | 16 +- .../ui-settings-general/README.i18n.yaml | 4 +- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../tests/apply.client.spec.ts | 10 +- .../tests/components.client.spec.tsx | 15 +- .../settings-document-store.client.spec.ts | 34 +- .../tests/shell.client.spec.ts | 4 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../tests/apply.client.spec.ts | 15 +- .../tests/components.client.spec.tsx | 51 +-- .../tests/onboarding-dialog.client.spec.tsx | 33 +- .../tests/provider-form.client.spec.tsx | 88 ++-- .../tests/store.client.spec.ts | 27 +- packages/client/ui-skill/README.i18n.yaml | 4 +- packages/client/ui-skill/README.md | 4 +- packages/client/ui-skill/README.zh.md | 4 +- .../tests/browser-plugin.client.spec.ts | 29 +- .../tests/workspaces-service.client.spec.ts | 57 +-- .../context/file-reference/README.i18n.yaml | 4 +- packages/context/file-reference/README.md | 8 +- packages/context/file-reference/README.zh.md | 8 +- .../file-reference/tests/service.spec.ts | 6 +- .../src/client/api-catalog.ts | 5 - .../src/client/slot-catalog.ts | 4 +- .../extensions/tool-cordis/src/api-catalog.ts | 137 ++++++- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 34 +- packages/host/apiproxy/README.zh.md | 34 +- .../tests/api-proxy-agent-preset.spec.ts | 375 ------------------ .../apiproxy/tests/api-proxy-config.spec.ts | 306 +------------- .../apiproxy/tests/api-proxy-host.spec.ts | 27 +- .../tests/api-proxy-skills-cold.spec.ts | 87 ---- .../apiproxy/tests/client-handler.spec.ts | 117 ------ .../host/apiproxy/tests/fetch-carrier.spec.ts | 137 +------ .../host/apiproxy/tests/rpc-schemas.spec.ts | 32 -- .../llm/llm-pi-ai/tests/discovery.spec.ts | 6 +- packages/util/native-command/README.i18n.yaml | 4 +- packages/util/native-command/README.md | 19 +- packages/util/native-command/README.zh.md | 19 +- packages/util/native-command/package.json | 2 +- .../native-command/tests/path-opener.spec.ts} | 3 +- pnpm-lock.yaml | 60 ++- scripts/gen-cordis-catalog.ts | 9 + scripts/gen-cordis-inspect-catalog.ts | 2 +- scripts/gen-doc-graphs.ts | 20 +- 158 files changed, 1754 insertions(+), 2293 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md create mode 100644 .agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md delete mode 100644 .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml delete mode 100644 .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md delete mode 100644 .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md create mode 100644 packages/api/session-controller/tests/file-references.host.spec.ts create mode 100644 packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts create mode 100644 packages/api/session-controller/tests/session-skills.host.spec.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts rename packages/{host/apiproxy/tests/native-path-opener.spec.ts => util/native-command/tests/path-opener.spec.ts} (99%) diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 64e6bbc289..1548b145b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 6f159dc16e0e4063f9077c31829a10caca98eae0 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 2cb082dce75f1845e52a52289a8e3eaa1a000931 +2026-07-25-web-client-session-scope-and-provide-channel.md: 1fa442e8db2d8b2d2ec66730700c9c88dceddbae +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: ea9a6e247402e6a2d15fb4bfc0ebd6e65fc021df diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 6f159dc16e..1fa442e8db 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -107,7 +107,7 @@ Slot scope is the closed set `root | session-maybe | session`: - The summary `blank` column and the `host/session-added` frame's `blank` field (see the blank bit above). - The SSE frame `host/commands-changed` (a pure invalidation signal); the client routes it into the typed events `commands/changed` and `connection/reset` (broadcast after each connection generation is established; wire-derived caches uniformly treat prior state as stale). The commands frame and its typed client event were later replaced by verbatim forwarding of `commands/change` through `ctx.remote.$on` ([forwarded Remote events](2026-08-10-remote-event-delivery.md)); `connection/reset` is unchanged, and the invalidation-not-diffing contract this bullet states still holds. -- `command.list/execute` and `skill.list` are uniformly single-addressed by `sessionId` (a session always has an Agent; `agentFor`'s resume semantics come ready-made); the command-surface narrative lives in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). +- `command.list/execute` and `skills/list` are uniformly single-addressed by `sessionId` (a session always has an Agent; `agentFor`'s resume semantics come ready-made); the command-surface narrative lives in the [command surfaces note](2026-07-25-web-command-surfaces-and-assembly.md). - The `session.create` request shape: workspaceId/cwd as either-or, plus an optional caller-preallocated sessionId (a same-id same-cwd retry is idempotent; a different cwd reports `session-conflict`). ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 2cb082dce7..ea9a6e2474 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -107,7 +107,7 @@ slot scope 是闭集 `root | session-maybe | session`: - summary `blank` 列与 `host/session-added` 帧 `blank` 字段(见上文 blank 位)。 - SSE(Server-Sent Events)帧 `host/commands-changed`(纯失效信号);client 路由为类型事件 `commands/changed` 与 `connection/reset`(连接代建立后广播,wire 派生缓存一律视旧态为陈旧)。 该 commands 帧及其类型化 client 事件后来被「`commands/change` 经 `ctx.remote.$on` 原样转发」取代([转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md));`connection/reset` 不变;本条陈述的「失效而非差分」契约依然成立。 -- `command.list/execute`、`skill.list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的恢复语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)。 +- `command.list/execute`、`skills/list` 一律 `sessionId` 单址(会话恒有 Agent,`agentFor` 的恢复语义现成);命令面叙述见[命令业务面 note](2026-07-25-web-command-surfaces-and-assembly.zh.md)。 - `session.create` 请求形状:workspaceId/cwd 二选一 + 可选调用方预分配 sessionId(同 id 同 cwd 重试幂等,异 cwd 报 `session-conflict`)。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml index 9bbd5cdb6a..c04c301afa 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md -2026-07-25-web-command-surfaces-and-assembly.md: a3628a7d99a6ab33652c67f6188933dea213194b -2026-07-25-web-command-surfaces-and-assembly.zh.md: eef93d750c502f5c034b6604774de31d75d6d342 +2026-07-25-web-command-surfaces-and-assembly.md: 943b68416d896c674783156b05997840c6df3255 +2026-07-25-web-command-surfaces-and-assembly.zh.md: c35d47202894af99377932df57e5de118186c1ce diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md index a3628a7d99..943b68416d 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.md @@ -28,7 +28,7 @@ The pipeline was ready but command knowledge had no landing spot: host-side `ctx ### Reference sources (seeing only projections plus their own apply closures, on the root ctx) -- **ui-skill**: `skill.list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, the plain-text-reference decision); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). +- **ui-skill**: `skills/list({sessionId})` addresses by session (the host resolves the project root from the session header); the directory cache is single-flight keyed by sessionId, prewarmed at birth by the `warm` hook and fully cleared by `connection/reset`. A pick produces a text outcome (the literal `/name ` text, the plain-text-reference decision); `lexicon` supplies the roster from CatalogFetch's settled snapshot (`undefined` while not warm), and `subscribeLexicon` notifies per-session listeners on settle and on invalidation. No match hook (references never enter command adjudication). Skill references ride ordinary prompts as literal text (outside the command plane; tool-skill unchanged, with the session-prefix directory providing the cooperative association). - **ui-subagent**: candidates are zero-RPC (the sessions.list snapshot filtered by parentId/running); a pick produces a text outcome (the literal `@name ` text); `lexicon` derives from the same snapshot and `subscribeLexicon` forwards the list store's change feed (the model-side representation awaits its business workstream). ### Fixture command routing and assembly diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md index eef93d750c..c35d472028 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-command-surfaces-and-assembly.zh.md @@ -28,7 +28,7 @@ Status: implemented ### 引用源(只见投影 + 自家 apply 闭包的 root ctx) -- **ui-skill**:`skill.list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,纯文本引用决策);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通提示词走(命令平面之外;tool-skill 不变,会话前缀目录提供协作关联)。 +- **ui-skill**:`skills/list({sessionId})` 按会话寻址(host 从会话 header 解析项目根);目录缓存按 sessionId 键控 single-flight,`warm` 钩子出生预热、`connection/reset` 全清。pick 产出 text outcome(`/name ` 原文,纯文本引用决策);`lexicon` 从 CatalogFetch 的 settled 快照给名录(未热 `undefined`),`subscribeLexicon` 在 settle 与失效时按会话通知监听者。无 match 钩子(引用不进命令裁决)。skill 引用以原文随普通提示词走(命令平面之外;tool-skill 不变,会话前缀目录提供协作关联)。 - **ui-subagent**:候选零 RPC(sessions.list 快照按 parentId/running 过滤);pick 产出 text outcome(`@name ` 原文);`lexicon` 同快照派生,`subscribeLexicon` 转发 list store 的变更通道(模型侧表示待业务立项)。 ### fixture 命令路由与装配 diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 6e02fa3915..16238db465 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-web-config-plane.md -2026-07-30-web-config-plane.md: c817071ed17554d06249aa6893ed759bea5d72d0 -2026-07-30-web-config-plane.zh.md: 0b15e329340051d0f63e8a5b1f8c70a29a2138d2 +2026-07-30-web-config-plane.md: 81b501db529bf1b2974fd4541045991c5a8bf087 +2026-07-30-web-config-plane.zh.md: 3f02a17e4826bb35ecfd45da25c4b0170be270cb diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index c817071ed1..81b501db52 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -12,11 +12,11 @@ The request-level configuration seam made LLM adapter configuration restart-free ## Decision -**Configuration calls use their owning wire implementation, rejections as codes, and owner events forwarded verbatim.** `@deepseek-ai/dsh-api-settings-controller` owns generated Remote methods for `settings/describe`, `settings/update`, `settings/replace`, `settings/mutate`, and `credentials/describe|set|unset`; `settings.openDocument` and the `llm.*` methods remain in `RpcMethodMap`. Provider absence retains the configuration API's actionable `internal` diagnostic, while seam rejections retain `settings-rejected {ns}` / `settings-conflict {ns, expected, actual}` / `credential-rejected {ref}`. Clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates generated Remote methods and API Proxy fallbacks with the same browser session; Host/Origin failures still return 403 before identity is checked. +**Configuration calls use their owning wire implementation, rejections as codes, and owner events forwarded verbatim.** `@deepseek-ai/dsh-api-settings-controller` owns generated Remote methods for `settings/describe`, `settings/update`, `settings/replace`, `settings/mutate`, `settings/openSettingsDocument`, `settings/openAgentPresetDirectory`, and `credentials/describe|set|unset`; `@deepseek-ai/dsh-llm` owns `llm/listProviders`, `llm/listConfigurableProviders`, and `llm/discoverModels`. Provider absence retains the configuration API's actionable `internal` diagnostic, while seam rejections retain `settings-rejected {ns}` / `settings-conflict {ns, expected, actual}` / `credential-rejected {ref}`. Clients subscribe to forwarded settings, credentials, and LLM owner events and converge without polling ([forwarded Remote events](2026-08-10-remote-event-delivery.md)). Connection authenticates generated Remote methods and API Proxy fallbacks with the same browser session; Host/Origin failures still return 403 before identity is checked. **`describe()` grows layers and structural secret redaction.** `SettingsDescriptor` carries `base`/`user` beside the effective value, so the form marks "overridden" by presence in the user layer, not value inequality (an override *equal* to the base is still an override). `describe({ redactSecrets: true })` — mandatory at every wire face — strips `role('secret')` subtrees from all three layers via a pure structural walk of the schema (object/dict/array containers; a secret-role subtree is one opaque leaf) and enumerates the stripped slots as `{path, set}`, so a page can render write-only inputs without ever receiving a value. -**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The browser-authenticated `settings.describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings.openDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; non-loopback pages retain the Client policy that makes no Host settings read for this action. +**The Host identifies and opens the local settings document.** The settings seam exposes optional `documentPath` provider metadata and a `prepareDocument()` operation; `settings-file` returns its fully resolved custom or `$DSH_HOME/settings.yaml` filename and exclusively creates an absent empty document with owner-only permissions, while non-file providers retain the base `undefined`. The browser-authenticated `settings/describe` response carries only the boolean `hasDocument` capability beside the redacted namespace views. `ui-settings-general` registers a `settings.action` entry only on loopback pages, shows it only after the metadata confirms that a provider-owned local document can be prepared, and invokes pathless `settings/openSettingsDocument`; the Host resolves the provider path again before a text-document handoff (`open -t` on macOS so an arbitrary YAML file association cannot redirect the gesture, `xdg-open` on desktop Linux, `Invoke-Item` on Windows, and `wslpath -w` followed by that Windows handoff on WSL). Generic workspace paths retain the default intent, including its browser preference for browser-renderable documents. The browser neither derives `$DSH_HOME` nor receives a filesystem target; non-loopback pages retain the Client policy that makes no Host settings read for this action. **The llm seam declares configurability and announces topology.** `registerConfigurableProviders()` is an all-or-nothing, fiber-scoped directory of `{provider, displayName, settingsNs, settingsPath}` — the addressing a config page needs to open the right settings subtree for a route that may not exist yet; `listConfigurableProviders()` merges with live routes in the wire handler so undeclared live routes still report active. The zero-payload `'llm/adapters-updated'` event fires from all four registration/unregistration commit points with contained listener dispatch (INVARIANT rethrow), following the settings/commands precedent. `llm-deepseek`'s route renamed to `deepseek-official` because the pi-ai catalog legitimately owns `deepseek` as an aggregator entry; pre-release stance, no alias. @@ -32,7 +32,7 @@ The request-level configuration seam made LLM adapter configuration restart-free - **Storing the typed key as a literal `apiKey` setting** — the single API key input requirement could have written the literal into the profile, but every UI removal path rebuilds the user section from the *redacted* layers, so any reset or row deletion would silently drop stored sibling keys; deriving a reference keeps the input single-field while keeping `settings.yaml` secret-free and every replace safe. - **A `models` bridge plugin owning provider configuration** — same rejection as in the request-level seam note: per-plugin namespaces plus a four-field directory declaration give the UI everything it needs; the bridge's unified dict re-imports the adapter-mapping indirection. - **Page-side polling instead of pushed frames** — the mux already carries `host/commands-changed`; three more frames cost one shape each and make a second tab, an external `settings.yaml` edit, and a settings-born route converge at event speed. -- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `host.openPath` in the browser** — rejected because `settings-file.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. +- **Hard-coding `$DSH_HOME/settings.yaml` or returning `documentPath` through `session/openWorkspacePath` in the browser** — rejected because `settings-file.path` may select another YAML/JSON document, non-file providers have no Host path, and a general path request makes the browser the authority for a local filesystem target. Provider preparation is the authoritative source, and the Host-owned operation feeds the existing opener. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 0b15e32934..3f02a17e48 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -**配置调用使用其所属的 wire 实现,拒绝落为错误码,owner 事件原样转发。**`@deepseek-ai/dsh-api-settings-controller` 持有 `settings/describe`、`settings/update`、`settings/replace`、`settings/mutate` 与 `credentials/describe|set|unset` 的生成 Remote 方法;`settings.openDocument` 和 `llm.*` 方法仍位于 `RpcMethodMap`。provider 缺失时保留配置 API 可操作的 `internal` 诊断,seam 拒绝则保留 `settings-rejected {ns}`/`settings-conflict {ns, expected, actual}`/`credential-rejected {ref}`。Client 订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 使用同一个浏览器会话认证生成的 Remote 方法与 API Proxy 回退;Host/Origin 失败仍会在身份校验前返回 403。 +**配置调用使用其所属的 wire 实现,拒绝落为错误码,owner 事件原样转发。**`@deepseek-ai/dsh-api-settings-controller` 持有 `settings/describe`、`settings/update`、`settings/replace`、`settings/mutate`、`settings/openSettingsDocument`、`settings/openAgentPresetDirectory` 与 `credentials/describe|set|unset` 的生成 Remote 方法;`@deepseek-ai/dsh-llm` 持有 `llm/listProviders`、`llm/listConfigurableProviders` 与 `llm/discoverModels`。provider 缺失时保留配置 API 可操作的 `internal` 诊断,seam 拒绝则保留 `settings-rejected {ns}`/`settings-conflict {ns, expected, actual}`/`credential-rejected {ref}`。Client 订阅转发的 settings、credentials 与 LLM owner 事件,无需轮询即可收敛(见[转发的 Remote 事件](2026-08-10-remote-event-delivery.zh.md))。Connection 使用同一个浏览器会话认证生成的 Remote 方法与 API Proxy 回退;Host/Origin 失败仍会在身份校验前返回 403。 **`describe()` 增加分层与结构化 secret 脱敏。**`SettingsDescriptor` 在生效值之外携带 `base`/`user`,表单据此按「字段是否出现在用户层」来标记「已覆盖」,而非按值是否不等(与 base *相等*的覆盖仍然是覆盖)。`describe({ redactSecrets: true })`——在每个 wire 面都强制启用——经由对 schema 的纯结构遍历(object/dict/array 容器;secret 角色子树整体是一个不透明叶节点)从全部三层剥除 `role('secret')` 子树,并把剥除的槽位枚举为 `{path, set}`,页面因此不必收到任何值就能渲染只写输入框。 -**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。经浏览器认证的 `settings.describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由提供方持有的本地文档后才显示,并调用无路径参数的 `settings.openDocument`;Host 会在文本文档交接前再次解析提供方路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;非 loopback 页面保留 Client 策略,不为这项操作发起 Host settings 读取。 +**Host 识别并打开本地设置文档。** settings seam 暴露可选的 `documentPath` 提供方元数据和 `prepareDocument()` 操作;`settings-file` 返回已完全解析的自定义文件名或 `$DSH_HOME/settings.yaml` 文件名,并在文档缺失时以仅属主可访问的权限独占创建空文档,非文件提供方则保留基类的 `undefined`。经浏览器认证的 `settings/describe` 响应会在脱敏 namespace 视图旁只携带布尔型 `hasDocument` 能力。`ui-settings-general` 只在回环页面注册一条 `settings.action` 条目,只有元数据确认可准备好一份由 provider 持有的本地文档后才显示,并调用无路径参数的 `settings/openSettingsDocument`;Host 会在文本文档交接前再次解析 provider 路径(macOS 上使用 `open -t`,使任意 YAML 文件关联无法重定向这次操作;桌面 Linux 上使用 `xdg-open`;Windows 上使用 `Invoke-Item`;WSL 上先执行 `wslpath -w`,再使用同一 Windows 交接)。通用 Workspace 路径仍保留默认意图,包括针对浏览器可渲染文档的浏览器偏好。浏览器既不推导 `$DSH_HOME`,也不会收到文件系统目标;非 loopback 页面保留 Client 策略,不为这项操作发起 Host settings 读取。 **llm seam 声明可配置性并公布拓扑。**`registerConfigurableProviders()` 是一个全有或全无、以 fiber 为作用域的目录,条目为 `{provider, displayName, settingsNs, settingsPath}`——这正是配置页要为一条可能尚不存在的路由打开正确设置子树时所需要的寻址;`listConfigurableProviders()` 在 wire 处理器里与存活路由合并,未声明的存活路由因此仍报告为激活。零负载的 `'llm/adapters-updated'` 事件从全部四个注册/注销提交点触发,listener 派发带异常隔离(INVARIANT 重抛),沿用 settings/commands 的先例。`llm-deepseek` 的路由重命名为 `deepseek-official`,因为 pi-ai catalog 名正言顺地拥有 `deepseek` 这个聚合器条目;依预发布立场,不设别名。 @@ -32,7 +32,7 @@ Status: implemented - **把键入的密钥存成字面 `apiKey` 设置**——单个 API 密钥输入框的需求本可以把字面量直接写进 profile,但 UI 的每条删除路径都会从*脱敏后的*各层重建用户分节,任何重置或整行删除都会静默丢掉已存储的兄弟密钥;派生引用让输入保持单字段,同时让 `settings.yaml` 不含机密、每一次 replace 都安全。 - **由 `models` 桥接插件持有提供方配置**——与请求级 seam note 相同的否决理由:按插件划分的 namespace 加上四字段的目录声明已经给了 UI 需要的一切;桥接层的统一字典会把适配器映射那层间接重新引进来。 - **页面侧轮询而非推送帧**——mux 已经承载 `host/commands-changed`;再加三个帧,每个只需增加一种形状,就能让第二个标签页、外部的 `settings.yaml` 编辑和由设置催生的路由都以事件速度收敛。 -- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `host.openPath` 回传 `documentPath`**——否决,因为 `settings-file.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 +- **在浏览器中硬编码 `$DSH_HOME/settings.yaml`,或经 `session/openWorkspacePath` 回传 `documentPath`**——否决,因为 `settings-file.path` 可能选择另一份 YAML/JSON 文档、非文件提供方没有 Host 路径,而且通用路径请求会让浏览器成为本地文件系统目标的权威。提供方的准备操作才是权威来源,由 Host 持有的操作会把结果交给现有打开器。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml new file mode 100644 index 0000000000..617d6b07db --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.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-10-unary-apiproxy-remote-migration.md +2026-08-10-unary-apiproxy-remote-migration.md: 027376cbf772043cce21f487c94288cad6bece1c +2026-08-10-unary-apiproxy-remote-migration.zh.md: f7507959a992dd17ca60883ada7769c7196fdc3a diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md new file mode 100644 index 0000000000..027376cbf7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -0,0 +1,59 @@ +# Agent Note: Place unary browser operations on owning Remote services + +Status: implemented + +English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md) + +## Problem + +The Host API Proxy duplicated simple unary operations across business Services, API Proxy interfaces, Zod schemas, route tables, client stubs, and Client callers. [Typert Remote calls](2026-08-02-typert-remote-method-calls.md) already let a business package own this class of call, but moving an endpoint without its lifecycle and projection policy could change observable behavior. + +Agent-bound calls require particular care. Shared lookup policy reuses live Agents, resumes ordinary cold Sessions with their recorded presets, deduplicates concurrent resumes, and rejects subagent-owned identities. Skill listing instead must inspect a Session without activating its Agent. Native desktop operations must keep the browser from choosing an arbitrary Host path. + +## Decision + +Simple unary operations live on their natural business Remote owner. The business package owns the Remote signature and Host adaptation; `@deepseek-ai/dsh-api-remotes/client` selects its generated contribution; the Client package owns presentation joins. The API Proxy retains only `host.describe` and streamed `GET`/`HEAD /api/session.export`. + +| Legacy RPC | Remote destination | Owner and preserved behavior | +|---|---|---| +| `session.rename` | `sessionTitle/rename` | `SessionTitleService` resolves the Session through the shared lookup policy and returns the title event sequence. | +| `command.list`, `command.execute` | `commands/list`, `commands/execute` | `CommandRuntime` preserves Agent lookup, unmatched commands, and caller cancellation. | +| `llm.providers` | `llm/listProviders`, `llm/listConfigurableProviders` | `LlmRuntime` owns provider facts; Clients join live and configurable rows. | +| `llm.discoverModels` | `llm/discoverModels` | `LlmRuntime` preserves provider discovery, cancellation, and sanitized failures. | +| `llm.models` | `session/modelCatalog` | `SessionController` owns the Host-generation catalog, default selection, and isolated provider failures. | +| `credentials.describe`, `credentials.set`, `credentials.unset` | `credentials/describe`, `credentials/set`, `credentials/unset` | `CredentialsController` preserves reference validation, field projection, provider diagnostics, and refusal mapping. | +| `settings.describe`, `settings.update`, `settings.replace`, `settings.mutate` | Equivalent `settings/*` methods | `SettingsController` preserves redaction, mutation semantics, revision checks, and provider failures. | +| `settings.openDocument` | `settings/openSettingsDocument` | `SettingsController` prepares the provider-owned document and opens it with text-editor intent. | +| `agentPreset.read`, `agentPreset.copy`, `agentPreset.remove` | Equivalent `agentPresets/*` methods | `AgentPresetService` owns document reads, copies, and removals. | +| `agentPreset.openDocument` | `settings/openAgentPresetDirectory` | `SettingsController` resolves the preset directory and returns its path when native opening is unavailable. | +| `subagent.interrupt` | `subagents/interruptByParent` | The subagent service preserves parent authority without activating either Agent. | +| `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | Equivalent `workspace/*` methods | The Workspace registry owns detached snapshots and serialized mutations. | +| `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | +| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | +| `host.openPath` | `session/openWorkspacePath` | `SessionController` resolves the path against the addressed Session's workspace before native opening. | + +The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. + +The native path implementation lives in `@deepseek-ai/dsh-native-command`. Session and Settings controllers select the target; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. + +## Browser authentication + +Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Remote-owned endpoints and retained API Proxy endpoints therefore require the same browser session and Host/Origin checks. + +## Verification + +Focused Host and Client tests cover Remote calls, lookup and no-activation policy, native opening, error projection, and removal of legacy routes. The repository build generates and consumes the selected Remote contributions before building the Web application. + +## Alternatives considered + +**Keep simple calls in the API Proxy.** Rejected because it preserves duplicate interfaces, schemas, route rows, stubs, and result projections after a business owner exists. + +**Move every unary operation.** Rejected because `host.describe` combines deployment facts and Connection readiness, while Session export is a streamed download rather than a unary business method. + +**Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports without making the browser authoritative for filesystem targets. + +## Consequences + +Business owners and Client consumers each define one side of a unary operation, while Connection retains authentication, transport, and response envelopes. Removing the legacy client timeout is the accepted observable transport change; business results, cancellation, lifecycle policy, filtering, and native-path authority remain owned by their existing domains. + +Generated Remote artifacts and the explicit API Remotes assembly become required whenever a Remote signature or selected package changes. diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md new file mode 100644 index 0000000000..f7507959a9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 将一元浏览器操作放到所属 Remote 服务 + +Status: implemented + +[English](2026-08-10-unary-apiproxy-remote-migration.md) | 中文 + +## 问题 + +Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由表、Client stub 与 Client 调用方之间重复定义简单一元操作。[Typert Remote 调用](2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包持有这类调用,但如果迁移 endpoint 时没有一并保留生命周期与投影策略,就会改变可观察行为。 + +与 Agent 绑定的调用需要格外谨慎。共享 lookup 策略会复用 live Agent、用记录的 preset 恢复普通冷 Session、对并发恢复去重,并拒绝由 subagent 持有的 identity。skill 列表则必须检查 Session 而不激活 Agent。原生桌面操作必须避免让浏览器选择任意 Host 路径。 + +## 决策 + +简单一元操作归属其自然的业务 Remote owner。业务包持有 Remote 签名与 Host 适配;`@deepseek-ai/dsh-api-remotes/client` 选择其生成贡献;Client 包持有呈现联接。API Proxy 只保留 `host.describe` 与流式 `GET`/`HEAD /api/session.export`。 + +| 旧 RPC | Remote 目标 | Owner 与保留行为 | +|---|---|---| +| `session.rename` | `sessionTitle/rename` | `SessionTitleService` 通过共享 lookup 策略解析 Session,并返回标题事件序号。 | +| `command.list`、`command.execute` | `commands/list`、`commands/execute` | `CommandRuntime` 保留 Agent lookup、未匹配命令与调用方取消。 | +| `llm.providers` | `llm/listProviders`、`llm/listConfigurableProviders` | `LlmRuntime` 持有 provider 事实;Client 联接 live 与 configurable 行。 | +| `llm.discoverModels` | `llm/discoverModels` | `LlmRuntime` 保留 provider 发现、取消与净化后的失败。 | +| `llm.models` | `session/modelCatalog` | `SessionController` 持有 Host generation 的目录、默认选择与隔离后的 provider 失败。 | +| `credentials.describe`、`credentials.set`、`credentials.unset` | `credentials/describe`、`credentials/set`、`credentials/unset` | `CredentialsController` 保留引用校验、字段投影、provider 诊断与拒绝映射。 | +| `settings.describe`、`settings.update`、`settings.replace`、`settings.mutate` | 对应的 `settings/*` 方法 | `SettingsController` 保留脱敏、mutation 语义、revision 校验与 provider 失败。 | +| `settings.openDocument` | `settings/openSettingsDocument` | `SettingsController` 准备 provider 持有的文档,并按文本编辑器意图打开。 | +| `agentPreset.read`、`agentPreset.copy`、`agentPreset.remove` | 对应的 `agentPresets/*` 方法 | `AgentPresetService` 持有文档读取、复制与删除。 | +| `agentPreset.openDocument` | `settings/openAgentPresetDirectory` | `SettingsController` 解析 preset 目录,并在原生打开不可用时返回其路径。 | +| `subagent.interrupt` | `subagents/interruptByParent` | subagent 服务保留 parent 权限,且不激活任何一方的 Agent。 | +| `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | 对应的 `workspace/*` 方法 | Workspace registry 持有脱离可变对象的 snapshot 与串行 mutation。 | +| `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | +| `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | +| `host.openPath` | `session/openWorkspacePath` | `SessionController` 先基于目标 Session 的 workspace 解析路径,再执行原生打开。 | + +共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 + +原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Session 与 Settings controller 选择目标;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 + +## 浏览器认证 + +Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整的 `/api` 请求。因此 Remote 持有的 endpoint 与保留的 API Proxy endpoint 要求相同的浏览器会话和 Host/Origin 校验。 + +## 验证 + +聚焦的 Host 与 Client 测试覆盖 Remote 调用、lookup 与不激活策略、原生打开、错误投影和 legacy 路由移除。仓库构建会先生成并消费所选 Remote contribution,再构建 Web 应用。 + +## 考虑过的替代方案 + +**将简单调用留在 API Proxy。** 否决,因为业务 owner 已存在后,这仍会保留重复的 interface、schema、路由行、stub 与结果投影。 + +**迁移每一个一元操作。** 否决,因为 `host.describe` 组合部署事实与 Connection readiness,而 Session export 是流式下载,不是一元业务方法。 + +**把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入,同时不让浏览器成为文件系统目标的权威。 + +## 后果 + +业务 owner 与 Client consumer 各自定义一元操作的一侧,而 Connection 继续持有认证、传输与响应 envelope。删除 legacy Client timeout 是已接受的可观察传输变化;业务结果、取消、生命周期策略、过滤与原生路径权限仍由既有领域持有。 + +每当 Remote 签名或所选包发生变化,都必须更新生成的 Remote 产物和显式 API Remotes assembly。 diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml index 7b2d89cd39..e482014ebc 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.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-17-settings-describe-mirror.md -2026-08-17-settings-describe-mirror.md: a3774699ff328a44aed192a16dea0fa19d03c83c -2026-08-17-settings-describe-mirror.zh.md: 1fad68ae6346d656cc7868121eac14fdf845f8dc +2026-08-17-settings-describe-mirror.md: 81be16c83e61f8c44b4903b4cc57e26d80fe065e +2026-08-17-settings-describe-mirror.zh.md: 6c37992b7de7c2c225bb2eb965f6f758b8be990e diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md index a3774699ff..81be16c83e 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.md @@ -30,5 +30,5 @@ The cold-boot budget is pinned at two reads by `apps/web/tests/startup-rpc-budge - Startup `settings.describe` went 15 → 2, and a new preference-owning plugin adds zero reads. - Every derived surface shows the same document revision at any moment; the per-reader guards (`refreshWelcomeIfLoaded`, `refreshPermissionIfLoaded`, `refreshDocumentIfLoaded`) and their subscriptions are gone. - The mirror refreshes on every document commit regardless of namespace, so an external settings edit now costs one background read even while no settings surface is open — the price of surfaces that open already fresh. The per-namespace `ns !== spec.namespace` filters are gone with the per-scope subscriptions. -- `credentials.describe` (3 startup calls), `agentPreset.list` (2), and `llm.providers` are separate sources and stay direct; the same mirror pattern fits them if they ever need it. +- `credentials/describe` (3 startup calls), `agentPresets/list` (2), `llm/listProviders`, and `llm/listConfigurableProviders` are separate sources and stay direct; the same mirror pattern fits them if they ever need it. - A new direct `settings.describe` caller in client code is a budget regression; the e2e's failure message says to grep for callers outside `ui-settings`. diff --git a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md index 1fad68ae63..6c37992b7d 100644 --- a/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-17-settings-describe-mirror.zh.md @@ -30,5 +30,5 @@ Status: implemented - 启动期 `settings.describe` 从 15 次降到 2 次,新增持有偏好设置的插件带来零次新增读取。 - 任一时刻每个派生面看到的都是同一份文档 revision;各读取方的防护(`refreshWelcomeIfLoaded`、`refreshPermissionIfLoaded`、`refreshDocumentIfLoaded`)及其订阅随之消失。 - 镜像对任何命名空间的文档提交都会刷新,因此在没有任何设置表面打开时,一次外部设置编辑现在也花费一次后台读取——这是「表面打开即新鲜」的代价。随着各 scope 订阅的删除,按命名空间的 `ns !== spec.namespace` 过滤一并消失。 -- `credentials.describe`(启动 3 次)、`agentPreset.list`(2 次)与 `llm.providers` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。 +- `credentials/describe`(启动 3 次)、`agentPresets/list`(2 次)、`llm/listProviders` 与 `llm/listConfigurableProviders` 是另外的数据源,保持直连;若将来需要,同一镜像模式对它们同样适用。 - 客户端代码中新增直连 `settings.describe` 调用即是预算回归;e2e 的失败信息会提示在 `ui-settings` 之外 grep 调用方。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 2cd0ed343b..bf8265d9a7 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 5f4aba19d147eae3f49fcefc9a8006d0f557dc6c -2026-08-18-session-history-and-event-transport.zh.md: dcf7e7ffc5ad325756b1fb37dd5ad60d2f0b3147 +2026-08-18-session-history-and-event-transport.md: 3d7c1ae262cca410554bcb6b1a8af35686315ba7 +2026-08-18-session-history-and-event-transport.zh.md: cb1e02de580896a6278bb657e2097b823343ad0b diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 5f4aba19d1..3d7c1ae262 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -136,7 +136,7 @@ If a page request is canceled with its physical carrier generation, the journal `packages/api/session-controller` provides Host `ctx.sessionController` and the generated `ctx.remote.session` namespace. -It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `llm.models` because it is not Session-specific. +It owns Session list, search, create, selectModel, rename, fork, prompt, attachment, updateQueue, cancel, page, follow, and control. The Host-generation model catalog is exposed separately through `session/modelCatalog` because it is not Session-specific. The package separates agent, commands, control, history, and list controllers internally, but Session identity resolution, activation policy, subagent ownership, and Remote error projection have one public owner. @@ -378,4 +378,4 @@ Remote waterfalls preserve first claim across multiple Clients, continuation of This decision extends the allowlist and single Cordis-signature design from [Remote event delivery](2026-08-10-remote-event-delivery.md): ordinary notifications use `emit`, while Agent-scoped async waterfalls use the same `ctx.remote.$on` surface with explicit `waterfall` mode. It creates no second invocation map. -This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md). +This decision takes over the Session, Workspace, and Host-event carriers retained by [simple unary API Proxy migration](2026-08-10-unary-apiproxy-remote-migration.md) while preserving the complete jobs snapshot, process-local lifecycle, and “observation does not resume an Agent” semantics required by [background job display](../feature/2026-08-08-web-background-job-display.md). diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index dcf7e7ffc5..cb1e02de58 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -136,7 +136,7 @@ repair 期间旧 window 保持可读;page 与期间积累的 live entries 拼 `packages/api/session-controller` 提供 Host `ctx.sessionController` 与生成的 `ctx.remote.session` namespace。 -它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `llm.models` 公开,因为它不属于特定 Session。 +它拥有 Session list、search、create、selectModel、rename、fork、prompt、attachment、updateQueue、cancel、page、follow 与 control。Host generation 的 model catalog 通过独立的 `session/modelCatalog` 公开,因为它不属于特定 Session。 包内的 agent、commands、control、history 与 list controller 分开实现,但 Session 身份解析、激活策略、subagent ownership 和 Remote 错误投影只有一个公开 owner。 @@ -378,4 +378,4 @@ Remote waterfall 保留多 Client 首个 claim、全体 `next` 后继续 Host ch 本决定扩展[Remote 事件投递](2026-08-10-remote-event-delivery.zh.md)的 allowlist 与单一 Cordis 签名设计:普通通知继续使用 `emit`,Agent-scoped async waterfall 使用同一 `ctx.remote.$on` 面和显式 `waterfall` mode;不建立第二套 invocation map。 -本决定接管[简单一元 API Proxy 迁移](../../proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 +本决定接管[简单一元 API Proxy 迁移](2026-08-10-unary-apiproxy-remote-migration.zh.md)中保留的 Session、Workspace 与 Host event carrier,并保留[后台任务展示](../feature/2026-08-08-web-background-job-display.zh.md)所要求的完整 jobs snapshot、进程内生命周期和“观察不恢复 Agent”语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml index 6660f864d6..d8e1977615 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.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-25-session-observations-and-projection-owned-client-state.md -2026-08-25-session-observations-and-projection-owned-client-state.md: e47f2fc75ecbca51d01af077f6c6ab98f4e275f9 -2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 527a4eb6b6765cba95d6067f2be60bff8f31a559 +2026-08-25-session-observations-and-projection-owned-client-state.md: 492640385215b059761b17a057328cc5c6d24bff +2026-08-25-session-observations-and-projection-owned-client-state.zh.md: 0b892a9cae2c999b4472dd46f19068e2b4139e60 diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md index e47f2fc75e..4926403852 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.md @@ -114,7 +114,7 @@ The list view reads the same per-Session store as the opened Session. Hints can The per-Session Client projection store accepts list hints, the follow baseline, and later whole-value frames under one higher-sequence-wins rule. It never folds Session events. A baseline or frame may advance a hinted value, while an older cut cannot overwrite a newer row. -Data that is not derived from one Session remains outside projections. `llm.models` owns the Host-generation model catalog, and `agentPreset.list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. +Data that is not derived from one Session remains outside projections. `session/modelCatalog` owns the Host-generation model catalog, and `agentPresets/list` owns the configurable preset roster. A selector combines the relevant catalog with the Session's `modelSelection` or `agentPreset` projection only when both inputs are ready. During refresh it may retain the last complete catalog; before the first complete pair it reports loading instead of rendering a guessed name or availability verdict. Client-local interaction state also remains local: loading and error status, an open menu, an in-flight selection, and a staged choice for a not-yet-created Session are not replayable Session facts. Once a choice applies to a Session, its durable event and projection become authoritative. diff --git a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md index 527a4eb6b6..0b892a9cae 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-session-observations-and-projection-owned-client-state.zh.md @@ -114,7 +114,7 @@ List view 与已打开 Session 读取同一个 per-Session store。Hints 可以 每个 Session 的 Client projection store 按一条 higher-sequence-wins 规则接收 list hints、follow baseline 和后续 whole-value frame。它从不折叠 Session event。Baseline 或 frame 可以推进 hinted value,较旧切面不能覆盖较新的 row。 -不由单个 Session 派生的数据不进入 projection。`llm.models` 拥有当前 Host generation 的 model catalog,`agentPreset.list` 拥有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 +不由单个 Session 派生的数据不进入 projection。`session/modelCatalog` 持有当前 Host generation 的 model catalog,`agentPresets/list` 持有可配置 preset roster。Selector 只在相应 catalog 与 Session 的 `modelSelection` 或 `agentPreset` projection 均就绪后组合两者。刷新时可以保留上一份完整 catalog;第一次获得完整输入前显示 loading,而不是展示猜测的名称或可用性结论。 Client 本地交互状态也继续留在本地:loading 和 error 状态、打开的菜单、进行中的选择,以及为尚未创建 Session 暂存的选择都不是可回放 Session 事实。选择一旦应用到 Session,其持久事件与 projection 就成为权威。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml index cc3873f137..377d9ea1aa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.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-12-onboarding-reads-every-provider.md -2026-08-12-onboarding-reads-every-provider.md: 1f247a6c93257c24052f55eb4297ec3c9c3df06d -2026-08-12-onboarding-reads-every-provider.zh.md: fc6e43195a46eaea881f8b4bee3219b5e583b284 +2026-08-12-onboarding-reads-every-provider.md: 43895d6bc317f13ece91a48f9b44c0e8da705dc8 +2026-08-12-onboarding-reads-every-provider.zh.md: 67f12dd9e90435f61776d0eac048d588fcdd7252 diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md index 1f247a6c93..43895d6bc3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.md @@ -24,7 +24,7 @@ Each card kind now owns its own close handler. `closeSetup` records the provider ## Alternatives considered -- **Deriving readiness from the model catalog (`llm.models`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. +- **Deriving readiness from the model catalog (`session/modelCatalog`) instead of the join.** It answers "can the user talk to something" most directly, but it costs a per-provider listing round trip on a surface that already holds the join, and a provider whose listing fails transiently would re-open onboarding. - **Requiring `row.configured` in `providerUsable`.** It reads as the stricter check, and would exclude exactly the routes a deployment mounts through `cordis.yml` without a configurable-provider declaration — live routes serving models that this page cannot configure. Registration, not configurability, is what makes a provider usable. - **Only adding the dismissal, leaving the card auto-opening.** It fixes the Cancel button and nothing else: a user with a working provider would still be handed the DeepSeek form on every visit to Models, which is the same misreading in a quieter form. - **Persisting the dismissal to settings.** A durable "do not ask about DeepSeek" flag is a second fact about first-run state that can disagree with the join. The credential itself already ends the posture permanently, and every other card on this page is session-local. diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md index fc6e43195a..67f12dd9e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-12-onboarding-reads-every-provider.zh.md @@ -24,7 +24,7 @@ Status: implemented ## Alternatives considered -- **从模型目录(`llm.models`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 +- **从模型目录(`session/modelCatalog`)而非联接推导就绪状态。** 它最直接地回答「用户有没有能对话的东西」,但会在一个已经持有联接的界面上多花每提供方一次列举往返,而且某个提供方列举的瞬时失败会让引导重新弹出。 - **在 `providerUsable` 中要求 `row.configured`。** 它读起来更严格,却会恰好排除部署通过 `cordis.yml` 挂载、没有可配置提供方声明的那些路由——它们是正在提供模型、只是这个页面配置不了的存活路由。使一个提供方可用的是注册,不是可配置性。 - **只加关闭状态,保留卡片自动展开。** 那只修好取消按钮,别的什么都没修:已有可用提供方的用户每次进入 Models 仍会被塞一张 DeepSeek 表单,那是同一个误读的安静版本。 - **把关闭状态持久化到 settings。** 一个「别再问 DeepSeek」的持久标志,是关于首次运行状态的第二个事实,可能与联接互相矛盾。凭据本身已经永久结束该姿态,而这个页面上其他每一张卡片都是会话内的。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml index bcd28b5585..762dc3711f 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.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-18-tool-row-file-open-failure.md -2026-08-18-tool-row-file-open-failure.md: e36552395b992e688fad35b3163b92c9f6189e43 -2026-08-18-tool-row-file-open-failure.zh.md: f09c41585a538b408f6258f64583f63c0fa57b0d +2026-08-18-tool-row-file-open-failure.md: c1887c834933199d5acf9035632cc83d470777d7 +2026-08-18-tool-row-file-open-failure.zh.md: 8a851b4b1bd40c66de92316967df612f828c8b38 diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md index e36552395b..c1887c8349 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.md @@ -6,7 +6,7 @@ English | [中文](2026-08-18-tool-row-file-open-failure.zh.md) ## Problem -Tool-row path clicks already call `host.openPath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. +Tool-row path clicks already call `session/openWorkspacePath` through the chat view's injected `openFile`. The inject swallowed every Host or OS refusal, so a missing desktop opener, a remote or non-loopback carrier, or a path the Host cannot hand off left the row looking successful. The reader had no reason and no second try. The [file-open-in-OS decision](../feature/2026-07-28-tool-call-file-open-in-os.md) still owns the link gesture and the Host handoff. This note owns only the refusal. @@ -16,7 +16,7 @@ The inject returns the `workspaces.openPath` promise. The chat view wraps that o The dialog lives on the view that owns the Host call, not on each tool row. Produced-file chips and closing-message mentions use the same wrapper because they already share that opener. The produced-files folder action opens `.`, and that refusal uses the folder title and unknown-open copy. -The Host message is shown as thrown. `WorkspaceRuntime.openPath` prefixes `path open failed: ` onto the wire error; the dialog does not unwrap that prefix. +The Host message is shown as thrown. The chat view's `openFile` adapter prefixes `path open failed: ` onto the Remote error; the dialog does not unwrap that prefix. ## Alternatives considered @@ -30,4 +30,4 @@ A silent Host refusal is no longer a success from the reader's seat. Headless or ## Testing -Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `host.openPath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. +Package specs cover inject rejection, the dialog copy (Error, non-Error, empty, workspace folder), retry of the same path, cancel, and a settlement that arrives after dismiss. `apps/web/tests/seeded-history.e2e.ts` stubs `session/openWorkspacePath` to fail over a cold-resumed read row, pins the assembled dialog in `file-open-failure.expected.md`, and asserts the English reason plus a second call with the same payload. diff --git a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md index f09c41585a..8a851b4b1b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-18-tool-row-file-open-failure.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `host.openPath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 +工具行路径点击已经通过聊天视图注入的 `openFile` 调用 `session/openWorkspacePath`。inject 吞掉了每一次 Host 或操作系统拒绝,因此缺少桌面打开器、远程或非回环载体、或 Host 无法交接的路径,都会让该行看起来像成功。读者看不到原因,也无法再试一次。 [用系统应用打开文件的决策](../feature/2026-07-28-tool-call-file-open-in-os.zh.md) 仍然拥有链接手势和 Host 交接。本 Agent Note 只拥有拒绝路径。 @@ -16,7 +16,7 @@ inject 返回 `workspaces.openPath` 的 promise。聊天视图包装该打开器 对话框位于 chat 视图(拥有 Host 调用),而不是每个工具行。产物文件标签和收尾消息中的提及已经共用该打开器,因此走同一包装。产物文件的文件夹操作打开 `.`,该拒绝使用文件夹标题和未知打开回退文案。 -Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 +Host 消息按抛出内容展示。聊天视图的 `openFile` adapter 会在 Remote 错误前加上 `path open failed: ` 前缀;对话框不拆掉该前缀。 ## 考虑过的替代方案 @@ -30,4 +30,4 @@ Host 消息按抛出内容展示。`WorkspaceRuntime.openPath` 会在 wire 错 ## 测试 -包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `host.openPath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 +包测试覆盖 inject 拒绝、对话框文案(Error、非 Error、空文本、工作区文件夹)、同一路径重试、取消,以及关闭之后才落到的结果。`apps/web/tests/seeded-history.e2e.ts` 在冷恢复的 read 行上把 `session/openWorkspacePath` stub 为失败,用 `file-open-failure.expected.md` 钉住组装后的对话框,并断言英文原因以及对同一 payload 的第二次调用。 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml index f8d99db60e..ea9bcff6d2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.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-skill-invocation-policy.md -2026-07-28-skill-invocation-policy.md: 7a4f83ecae3dea82ab6864e748c9d8b8599f6bfc -2026-07-28-skill-invocation-policy.zh.md: 8d66af69b7f45c5d76d18963df6921eaec2e1e8c +2026-07-28-skill-invocation-policy.md: 918283bd028ec75faee965c35dfac494e9ceafad +2026-07-28-skill-invocation-policy.zh.md: ddf3a334561d2077c07f479792228a353cb926c0 diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md index 7a4f83ecae..918283bd02 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md @@ -18,7 +18,7 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation. -The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. +The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skills/list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added. These rules permit all four combinations: diff --git a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md index 8d66af69b7..ddf3a33456 100644 --- a/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.zh.md @@ -18,7 +18,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会 本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。 -面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill 对等依赖(peer dependency)变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 +面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill 对等依赖(peer dependency)变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skills/list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。 这些规则允许以下四种组合: diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index 824846180a..e97143a911 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: e6e590b2a97654b8b68d5a9842de10818f544088 -2026-07-28-tool-call-file-open-in-os.zh.md: eb600a69cb2a2c3cc0d7463519d3de4dce76047b +2026-07-28-tool-call-file-open-in-os.md: c8ede5c5c2fbdc9797edd9bf80673442c39873c2 +2026-07-28-tool-call-file-open-in-os.zh.md: 1e51dd4dced25bd14272358199baef82f396635a diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index e6e590b2a9..c8ede5c5c2 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -10,9 +10,9 @@ Chat tool rows treated the whole summary line as a click target that opened the ## Decision -File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `host.openPath` through `WorkspaceRuntime.openPath`, resolving relative paths against the session cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. +File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the Host resolves relative paths against the addressed Session's cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`host.openPath` is a privileged unary RPC accepted only from loopback, same-origin browser requests (same carrier guard as `host.pickDirectory`). Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. +`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `host.describe.canOpenPath` is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered @@ -23,7 +23,7 @@ File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `fil ## Consequences -Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). The Client withholds `host.openPath` on non-loopback pages; every exposed Host invocation still requires the browser session. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). +Clicking a file path in a tool row opens that path on the host. Non-file tool rows are inert summaries (expand toggles remain where the row already supported them). The Client withholds the file-open gesture on non-loopback pages; every exposed Host invocation still requires the browser session. A Host or OS refusal is owned by the chat view: it shows the thrown reason and retries the same path ([file-open failure](../bug-fix/2026-08-18-tool-row-file-open-failure.md)). ## Risks diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index eb600a69cb..1e51dd4dce 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经 `WorkspaceRuntime.openPath` 调用 `host.openPath`,相对路径以会话 cwd 为基准解析。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 +文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`;Host 以目标 Session 的 cwd 为基准解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`host.openPath` 是一元 RPC,与每个 Host API 方法一样要求通过 Host/Origin 校验和浏览器会话认证。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `host.describe.canOpenPath` 为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 @@ -23,7 +23,7 @@ Status: implemented ## 后果 -点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。Client 在非 loopback 页面不提供 `host.openPath`;每次已暴露的 Host 调用仍要求浏览器会话。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 +点击工具行中的文件路径会在宿主上打开该路径。非文件工具行只是不可交互的摘要(行内已有的展开开关仍保留)。Client 在非 loopback 页面不提供文件打开手势;每次已暴露的 Host 调用仍要求浏览器会话。Host 或操作系统拒绝由聊天视图拥有:它展示抛出的原因,并对同一路径提供重试([打开失败](../bug-fix/2026-08-18-tool-row-file-open-failure.zh.md))。 ## 风险 diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml index 00ee8cb309..fbddedc9e1 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.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-deepseek-onboarding-credential-setup.md -2026-07-30-deepseek-onboarding-credential-setup.md: 87533e7a55f9b1f05f6a4ba58c3c9888780c158c -2026-07-30-deepseek-onboarding-credential-setup.zh.md: 0b445d6eccdd9aa1651b64f084a96d4d674a4f12 +2026-07-30-deepseek-onboarding-credential-setup.md: c2e9a1251a4666d9b7109d284a1588640d630332 +2026-07-30-deepseek-onboarding-credential-setup.zh.md: e94f55949eb472d8deb524c33e9760155db0ddcf diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md index 87533e7a55..c2e9a1251a 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md @@ -10,7 +10,7 @@ The [web configuration plane](../architecture/2026-07-30-web-config-plane.md) ma ## Decision -**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm.providers({})`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials.describe({refs})`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. +**One readiness projection owns both Models and onboarding facts.** `ui-settings-models` keeps a single store that joins `llm/listProviders`, `llm/listConfigurableProviders`, the redacted namespace views held by the shared settings describe mirror, and batched `credentials/describe`. The onboarding projection selects the `deepseek-official` configurable-provider entry owned by the `llm-deepseek` namespace and empty settings path, reads the effective `apiKeyEnv`, and evaluates the matching credential descriptor. A live route with the same provider id but no matching configurable-provider declaration is adapter-absent for onboarding. A configured process-environment credential is ready and remains read-only. The later [settings describe mirror decision](../architecture/2026-08-17-settings-describe-mirror.md) owns that settings read and its invalidation ordering. **The settings shell contributes ordering, not provider policy.** `ui-settings` declares a root-scoped `settings.onboarding` list slot and mounts one ordered step at a time while the current surface is the empty Hero. The active registrant receives `complete()` and a private `openSection(id)` callback; completion transfers ownership to the next entry. `ui-settings-models` registers the DeepSeek step, the preceding welcome notice, and its Models section through `slots.inject()`, so every contribution follows one client Cordis plugin's lifecycle and the dialogs cannot stack. Their common presentation is owned by the [shared-modal onboarding decision](2026-08-13-shared-modal-product-onboarding.md). diff --git a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md index 0b445d6ecc..e94f55949e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm.providers({})`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials.describe({refs})` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.zh.md)持有这次 settings 读取及其失效顺序。 +**Models 与首次使用引导共享同一个就绪状态投影。**`ui-settings-models` 维护一个 store,把 `llm/listProviders`、`llm/listConfigurableProviders`、共享 settings describe 镜像持有的已脱敏 namespace views 和批量调用的 `credentials/describe` 联接为同一份状态。首次使用投影选取由 `llm-deepseek` namespace 与空 settings path 持有的 `deepseek-official` 可配置提供方条目,读取生效的 `apiKeyEnv`,并检查对应的凭据描述符。同 provider id 但没有匹配可配置提供方声明的存活路由,在首次使用引导中视为适配器缺失。通过进程环境提供的凭据若已配置,则判定为就绪并保持只读。后续的 [settings describe 镜像决策](../architecture/2026-08-17-settings-describe-mirror.zh.md)持有这次 settings 读取及其失效顺序。 **设置外壳只贡献排序,不持有提供方策略。** `ui-settings` 声明一个根作用域的 `settings.onboarding` list slot,并在当前界面为空白 Hero 时,每次只挂载一个有序步骤。当前注册方会收到 `complete()` 和私有 `openSection(id)` 回调;完成当前步骤后,所有权转交给下一项。`ui-settings-models` 通过 `slots.inject()` 注册 DeepSeek 步骤、排在它之前的欢迎声明及 Models 分区,因此所有贡献都跟随同一个 client Cordis 插件的生命周期,两个弹窗也无法堆叠。它们的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.zh.md)持有。 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 902227d012..5a5c329383 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.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-31-web-workspace-file-links.md -2026-07-31-web-workspace-file-links.md: 611f012e0201fa9002ea473ef9a107841bf83bcc -2026-07-31-web-workspace-file-links.zh.md: 6e8806ab41de88bf59e4f7bf642f78c3bfe50cb3 +2026-07-31-web-workspace-file-links.md: ddc093e1ce5e646f6b96f1517b2a26c9e10fe423 +2026-07-31-web-workspace-file-links.zh.md: f463de969bab7a47145abbbd46363b8807bcc673 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 611f012e02..ddc093e1ce 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -10,7 +10,7 @@ English | [中文](2026-07-31-web-workspace-file-links.zh.md) A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal. -Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: `ToolRow` already renders a mutation or read row's path as a real button wired to `host.openPath`, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability. +Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: `ToolRow` already renders a mutation or read row's path as a real button wired to `session/openWorkspacePath`, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability. ## Decision @@ -18,7 +18,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. -**Opening stays the Host's job, and prefers the default browser.** `host.openPath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive. +**Opening stays the Host's job, and prefers the default browser.** `session/openWorkspacePath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application when the platform can name one, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. macOS reads the LaunchServices `https` handler and desktop Linux reads `$BROWSER`; either falls back to the default application when no browser can be named. Windows uses its registered association, and WSL first translates the path before using that same Windows handoff. When files are hidden, **Show in folder** passes `.` through the same owner `openFile`; it appears only for a loopback page whose current `host.describe.canOpenPath` permits native opening. Other deployments omit it, with `nativeOpen: false` available when desktop detection would be a false positive. **Serving workspace files over HTTP is out of scope, and so are non-local clients.** Serving files from the harness itself — same-origin with `/api`, behind `CSP: sandbox`, or from a second listener whose own port gives served documents their own origin — was rejected with the product scope: previews for a browser that is not on the Host machine are not supported, so the Host opener answers the supported case completely and the HTTP machinery would answer only the unsupported one. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 6e8806ab41..f463de969b 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -10,7 +10,7 @@ Status: implemented 一个产出了文件的 web 会话,没有办法看到那个文件。agent(智能体)写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 -这背后是两个不同的缺陷。transcript(文本记录)从不说明一个轮次产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的「做完了打不开」,是一个可发现性失败叠在一项本就可用的能力之上。 +这背后是两个不同的缺陷。transcript(文本记录)从不说明一个轮次产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `session/openWorkspacePath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的「做完了打不开」,是一个可发现性失败叠在一项本就可用的能力之上。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 -**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。存在隐藏文件时,**在文件夹中显示**会把 `.` 经由同一 owner `openFile` 传递;它只在 loopback 页面的当前 `host.describe.canOpenPath` 允许原生打开时出现。其他部署会省略它;桌面探测误报时可配置 `nativeOpen: false`。 +**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `session/openWorkspacePath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——平台能够确定默认浏览器时,打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。macOS 读取 LaunchServices 的 `https` 处理程序,桌面 Linux 读取 `$BROWSER`;无法确定浏览器时,两者都会回退到默认应用。Windows 使用其注册的文件关联,WSL 则先转换路径,再使用同一 Windows 交接。存在隐藏文件时,**在文件夹中显示**会把 `.` 经由同一 owner `openFile` 传递;它只在 loopback 页面的当前 `host.describe.canOpenPath` 允许原生打开时出现。其他部署会省略它;桌面探测误报时可配置 `nativeOpen: false`。 **以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 由 harness 自己提供文件——与 `/api` 同源、置于 `CSP: sandbox` 之后、或交给一个以自身端口给所服务文档独立源的第二监听器——随产品范围一并否决:不为「浏览器不在 Host 机器上」的场景提供预览,因此 Host 打开器完整回答受支持的场景,而那套 HTTP 机制只会回答不受支持的那个。 diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml index 264571616c..9740b305f9 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.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-08-user-explicit-skill-invocation.md -2026-08-08-user-explicit-skill-invocation.md: 6e70c67f771afeec881bdd8ad5c5322cc9190dfe -2026-08-08-user-explicit-skill-invocation.zh.md: 00c76ab99a2074b5d7d84c870afd9a9190ec45df +2026-08-08-user-explicit-skill-invocation.md: 87f4d04fb4bc34cba2ecb5e362d9a86270ab5195 +2026-08-08-user-explicit-skill-invocation.zh.md: 7b1aa18808054edc38b6801e028de1f0f0605d4a diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md index 6e70c67f77..87f4d04fb4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-08-user-explicit-skill-invocation.zh.md) ## Problem -A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skill.list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. +A `disable-model-invocation: true` skill is user-only by design: it never enters the model-facing catalog and the `skill` tool refuses to load it. Its only legitimate entry point is an explicit user gesture — yet the web client had none. `skills/list` filtered to the model-and-user intersection (hiding user-only skills from the menu), an entered `/name` line rode into the default prompt sink as plain text, and the model it reached was forbidden to load the skill — so it degraded to `read`-ing the SKILL.md file or ignoring the gesture (issue #1470). Even for ordinary skills, the plain-text reference made user invocation a collaboration cue the model could ignore, not a guarantee. ## Decision @@ -14,7 +14,7 @@ User-explicit invocation is a host-side pre-step injection, uniform for every us - `dsh-tool-skill` registers a second `agent/pre-step` listener (beside its catalog listener, the same seam `agent-instructions` and the runtime-context snapshot ride): it scans the step's claimed messages for whitespace-bounded `/name` tokens — anywhere in the text, the same word-boundary shape the transcript chip decoration uses — collects first-seen-deduplicated names, loads each through `ctx.skills.get`, checks `isUserInvocable` on the loaded definition (the single lookup that produces what is injected), renders it with the shared `renderSkillContent`, and appends the injections after every other injection of the step: background first (workspace rules, runtime policy, catalog), the material the model must act on last, closest to its answer. Registration order pins the placement — the gesture listener registers before the catalog listener, so the waterfall hands it the catalog-bearing list to extend. - Precision is closed-set matching, exactly like slash commands: `/goal` resolves against the command registry, `/name` against the workspace's user-invocable skill directory; a miss stays ordinary prose, so nothing is ever guessed. Only `source.kind === 'user'` messages are scanned — external text cannot forge a gesture. Paths (`/usr/bin`), fractions (`5/8`), and prefixed tokens (`foo/name`) all break the boundary. -- The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skill.list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. +- The client keeps the [plain-text-reference decision](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.md): a menu pick lands the literal `/name ` and the prompt ships it verbatim; ui-skill implements no adjudication hooks and no reference codec. `skills/list` (now the domain's only RPC) serves every user-invocable skill with `modelInvocable` so menus mark user-only entries. A name shared with a host command resolves to the command — adjudication claims the line client-side before it becomes a prompt. - The injection is a `user`-role message carrying the `skill-invocation` source (`{ name, form: 'instructions' }`), so `user/message` logging, the context-injection transcript row (labelled with the skill name), and replay all come free; `renderSkillContent` lives in the `dsh-skill` seam, shared verbatim with the `skill` tool result, and the catalog's closing sentence tells the model to follow an injected block instead of re-loading it. Peer-product survey (Pi, OpenCode, Claude Code, Kimi Code, Codex, DeepSeek-Reasonix — local checkouts) was unanimous that user-explicit triggering is programmatic injection with zero model participation; the final shape is closest to Codex's core-side `$name` mention scanning, which likewise frees every entry point from implementing recognition. diff --git a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md index 00c76ab99a..7b1aa18808 100644 --- a/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-user-explicit-skill-invocation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skill.list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),输入的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 +`disable-model-invocation: true` 的 skill(技能)在设计上就是仅限用户的:它绝不进入面向模型的目录,`skill` 工具也拒绝加载它。它唯一正当的入口是一次显式的用户手势——而 web 客户端此前没有这个入口。`skills/list` 过滤到模型与用户的交集(把仅限用户的 skill 挡在菜单之外),输入的 `/name` 一行以纯文本落入默认提示词 sink,而这行文本到达的模型又被禁止加载该 skill——于是退化为模型去 `read` 那份 SKILL.md 文件,或者干脆无视这次手势(issue #1470)。即使对普通 skill,纯文本引用也让用户调用只是模型可以忽略的协作线索,而不是保证。 ## 决策 @@ -14,7 +14,7 @@ Status: implemented - `dsh-tool-skill` 注册第二个 `agent/pre-step` 监听器(与其目录监听器并列,也是 `agent-instructions` 与运行时上下文快照搭乘的同一 seam):它在该步骤已认领的消息中扫描以空白为界的 `/name` token——文本中任意位置均可,与 transcript(文本记录)chip 装饰所用的词边界形状相同——收集按首见去重的名称,逐个经 `ctx.skills.get` 加载,在已加载定义上检查 `isUserInvocable`(产生注入内容的正是这同一次查找),用共享的 `renderSkillContent` 渲染,并把注入追加在该步骤所有其他注入之后:背景在前(工作区规则、运行时策略、目录),模型必须着手处理的材料在最后、最贴近它的回答。注册顺序钉住了这一位置——手势监听器先于目录监听器注册,因此 waterfall(瀑布式事件)会把携带目录的列表交给它来扩展。 - 精确性来自封闭集合匹配,与斜杠命令完全一致:`/goal` 对照命令注册表解析,`/name` 对照工作区的用户可调用 skill 目录解析;未命中即保持为普通行文,因此绝不猜测。只扫描 `source.kind === 'user'` 的消息——外部文本无法伪造手势。路径(`/usr/bin`)、分数(`5/8`)与带前缀的 token(`foo/name`)都会破坏该边界。 -- 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skill.list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 +- 客户端沿用[纯文本引用决策](../architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md):菜单 pick 落下字面文本 `/name `,该文本随提示词原样提交;ui-skill 不实现任何裁决钩子,也没有引用 codec。`skills/list`(现在是该领域唯一的 RPC)提供每一个用户可调用的 skill 并携带 `modelInvocable`,供菜单标出仅限用户的条目。与宿主命令同名的名称解析为命令——客户端会在该行成为提示词之前完成裁决并将其认领。 - 注入是一条携带 `skill-invocation` 来源(`{ name, form: 'instructions' }`)的 `user` 角色消息,因此 `user/message` 落账、上下文注入的 transcript 行(以 skill 名称标注)与回放全部免费获得;`renderSkillContent` 位于 `dsh-skill` seam,由注入和 `skill` 工具结果共用,二者内容逐字相同,目录的结尾一句会告诉模型遵循注入块而不是重新加载。 同类产品调研(Pi、OpenCode、Claude Code、Kimi Code、Codex、DeepSeek-Reasonix——本地检出)一致表明:用户显式触发都是模型零参与的程序化注入;最终形态最接近 Codex 核心侧的 `$name` mention 扫描——它同样让每一种运行入口免于自行实现识别。 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml index 4ba77e05b1..2a8c4bbcb5 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.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-24-user-authorized-subagent-model-routes.md -2026-08-24-user-authorized-subagent-model-routes.md: 0f9816ae89562545c267d87713c13faecd9efc66 -2026-08-24-user-authorized-subagent-model-routes.zh.md: ca63215a5ec7ae56ae2f077a7fb19fc1e204527c +2026-08-24-user-authorized-subagent-model-routes.md: fce0026f6504298d2212ae52ad11aac862fc7e89 +2026-08-24-user-authorized-subagent-model-routes.zh.md: 2cdf42dd3394ea69072dd3cc5bcc2c90fd0b5e48 diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md index 0f9816ae89..fce0026f65 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.md @@ -10,7 +10,7 @@ Registering an LLM adapter makes its routes reachable, but does not authorize an ## Decision -The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `llm.models`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. +The Host-owned `subagent-model-selection` settings section stores an explicit `enabled` switch and `allowedModels`, an array of exact `{ provider, model }` routes. Enabling requires at least one route; disabling may retain the selected routes for later reuse. The Plugins settings card reads the live adapter directory through `session/modelCatalog`, lets the user stage the switch and routes, and saves both fields in one revision-fenced settings mutation. It stores no adapter-owned display names, descriptions, or reasoning-effort metadata. A stored or staged route absent from the current directory remains visible as unavailable and removable; a provider-local catalog failure does not block other providers or erase saved authorization or an unsaved selection. A connection reset discards the draft because namespace revisions are comparable only within one Host process. A newly composed top-level Session snapshots the route list in `subagent/model-selection-policy` when the setting is enabled, before its model-selectable definitions can reach a request. Event presence means selection was enabled; the event does not store the global switch. Child Sessions inherit that exact list from their live parent, and resumed Sessions use the recorded event instead of current settings. Settings changes therefore affect only subsequently composed top-level Sessions, while a non-empty legacy Session without the event remains disabled. diff --git a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md index ca63215a5e..2cdf42dd33 100644 --- a/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md +++ b/.agents/notes/implemented/feature/2026-08-24-user-authorized-subagent-model-routes.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `llm.models` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 +Host 自有的 `subagent-model-selection` 设置 section 保存显式 `enabled` 开关与 `allowedModels`,后者是由精确 `{ provider, model }` 路由组成的数组。启用时必须至少有一条路由;关闭时可以保留已选路由,供以后重新启用。Plugins 设置卡通过 `session/modelCatalog` 读取实时适配器目录,让用户暂存开关与路由,再在一次带 revision 限制的设置 mutation 中保存两个字段。它不保存适配器自有的显示名称、描述或推理强度元数据。当前目录中缺失的已存或暂存路由仍显示为不可用并允许移除;某个提供方的目录失败不会阻塞其他提供方,也不会清除已存授权或未保存选择。连接重置会丢弃草稿,因为 namespace revision 只能在同一个 Host 进程内比较。 设置启用时,新组合的顶层 Session 会在模型可选定义进入请求之前,把路由列表快照记录为 `subagent/model-selection-policy`。事件存在就表示模型选择已启用;事件不保存全局开关。子 Session 从在线父级继承同一份精确列表,恢复的 Session 使用已记录事件而不是当前设置。因此,设置修改只影响之后组合的顶层 Session,而已有非空日志但没有该事件的 Session 仍保持禁用。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index d98333c159..2b17c1d803 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.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/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: bfe0d49755abf47314a7b4cf56c738537fca3963 -2026-08-08-copy-only-preset-authoring.zh.md: 71e83d5e17c56e53ce4f4678b5a22baaed02be31 +2026-08-08-copy-only-preset-authoring.md: 54d317a3de236bf2e191424411c25291c52c7edd +2026-08-08-copy-only-preset-authoring.zh.md: ea449a7b73a0b5a7111ff924b6e47e6323bb58f1 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md index bfe0d49755..54d317a3de 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -10,7 +10,7 @@ The agent-preset settings page carried a web YAML editor: `agentPreset.write` ac ## Decision -Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `agentPreset.openDocument { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`, pinned by the gateway's `nativeOpen` config where `canOpenNativePath` platform detection would mislead, e.g. e2e and containers). +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `settings/openAgentPresetDirectory { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`; `host.describe.canOpenPath` gates the row, and Settings Controller's `nativeOpen` pins server behavior where platform detection would mislead). ## Consequences @@ -27,4 +27,4 @@ Authoring is a host-side copy, and files are the editor. `agentPreset.write` bec ## Alternatives considered -Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `host.openPath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. +Keeping write with a better editor (CodeMirror etc.): still arbitrary capability over the wire, still the race source, and still a worse editor than the user's own. Patch-semantics copies ("standard plus this diff"): no such layer exists below the bundle plane, and the repo's own shipped presets chose full copies deliberately. Browser-side `session/openWorkspacePath` with a returned path: breaks the README's no-arbitrary-target invariant the moment the path is a request parameter. diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index 71e83d5e17..ea449a7b73 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -10,7 +10,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 决策 -创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`agentPreset.openDocument { agentPreset }` 在宿主端解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示(`list` 上的 `hasDocument`;在 `canOpenNativePath` 平台探测会失真处由网关的 `nativeOpen` 配置钉死,例如 e2e 与容器)。 +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`settings/openAgentPresetDirectory { agentPreset }` 在 Host 侧解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示(`list` 上的 `hasDocument`;`host.describe.canOpenPath` 控制该行是否显示,Settings Controller 的 `nativeOpen` 则在平台探测可能误判时固定服务端行为)。 ## 后果 @@ -27,4 +27,4 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 考虑过的替代方案 -保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `host.openPath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 +保留 write 换个更好的编辑器(CodeMirror 等):传输层上仍是任意能力,仍是竞态来源,而且仍不如用户自己的编辑器。带 patch 语义的副本(「standard 加这点 diff」):bundle 面之下没有这样的层,仓库自己的随附 preset 也刻意选了完整副本。浏览器端拿返回路径调 `session/openWorkspacePath`:路径一旦成为请求参数,就打破了 README 的「不可选中任意目标」不变量。 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml deleted file mode 100644 index f7a2725d96..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: b63946b581d3a2afcd159e3b1c0ef44f824aa35c -2026-08-10-unary-apiproxy-remote-migration.zh.md: 92f40fc79f2be44855620b6b6915798834284747 diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md deleted file mode 100644 index b63946b581..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: Migrate simple unary API Proxy calls to business Remote services - -Status: proposed - -English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md) - -## Problem - -The Host API Proxy still owns many unary methods whose implementation is only service lookup, argument projection, one business call, and response projection. That duplicates the contract across the business Service, API Proxy interface, Zod schemas, route table, client stub, and Client caller even though [Typert Remote calls](../../implemented/architecture/2026-08-02-typert-remote-method-calls.md) already let the business package own this class of call. - -Moving a method mechanically is not sufficient. Agent-bound API Proxy methods call `agentFor()`, which reuses a live Agent, resumes an ordinary cold Session with its recorded preset, deduplicates concurrent resumes, and rejects subagent-owned identities. A Remote method that resolved an `Agent` or `Session` differently would change lifecycle behavior even when the final business call looked identical. - -The API Proxy also contains BFF operations whose contract is not a business method: Session lifecycle and transcript assembly, model-selection state, live-only input control, configuration filtering, skill presentation, Host composition facts, and native desktop operations. Stateful interactions and streams have different lifecycles again. Treating all unary syntax as evidence that a method is simple would move product policy into arbitrary Service packages or force new packages that have no independent business owner. - -Finally, Connection must authenticate a request before choosing the API Proxy fallback or a Typert interceptor. A migration that authenticates only the fallback would let Remote-owned endpoints bypass the browser identity required by every Host operation. - -## Proposal - -Migrate only unary calls whose business operation already has a natural Service owner and whose remaining adaptation is a small parameter or result projection. The Service binds a Typert namespace and decorates an existing method directly with `@Remote` when its signature is the intended consumer contract. A new method is justified only when it performs real adaptation; an identity `remote*` forwarding wrapper is not. - -`@deepseek-ai/dsh-api-remotes/client` will mount each selected business package's generated `/remote` contribution. Client business packages will call `ctx.remote.` and perform Client-owned joins or presentation projection there. The corresponding API Proxy interface member, schema, route, handler, generated client method, fixture implementation, and production invocation will be removed together in that Service's vertical commit. - -Large BFF methods remain in `dsh-host-apiproxy`. A method leaves this migration if implementation discovers endpoint-specific lifecycle policy, substantial orchestration, a Client dependency on a protocol-only error distinction, or a transport shape that cannot be expressed as a small owner-side adapter. - -## Migration set - -| Legacy RPC | Remote destination | Host method | Adaptation | -|---|---|---|---| -| `session.rename` | `ctx.remote.sessionTitle` in `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | Direct `@Remote`; Client maps `eventSeq` to its title projection sequence. | -| `command.list`, `command.execute` | `ctx.remote.commands` in `@deepseek-ai/dsh-commands` | `CommandRuntime.list(Agent)`, `execute(Agent, line, signal)` | Direct `@Remote`; Client maps `undefined` to unmatched and preserves caller cancellation. | -| `llm.providers` | `ctx.remote.llm` in `@deepseek-ai/dsh-llm` | `LlmRuntime.listProviders()`, `listConfigurableProviders()` | Direct `@Remote` on both reads; the Client joins registration and configuration-directory rows. | -| `credentials.describe`, `credentials.set`, `credentials.unset` | `ctx.remote.credentials` in `@deepseek-ai/dsh-api-settings-controller` | `CredentialsController.describe(refs)`, `set(ref, value)`, `unset(ref)` | The controller preserves batch size, reference validation, field projection, provider-absence diagnostics, and provider refusal mapping without adding wire behavior to the abstract Definition. | -| `settings.describe`, `settings.update`, `settings.replace`, `settings.mutate` | `ctx.remote.settings` in `@deepseek-ai/dsh-api-settings-controller` | `SettingsController.describe()`, `update(ns, patch, expectedRevision)`, `replace(ns, section, expectedRevision)`, `mutate(ns, ops, expectedRevision)` | The controller preserves redaction, all three write operations, optimistic revision checks, provider-absence diagnostics, and failure details. | -| `agentPreset.read`, `agentPreset.copy`, `agentPreset.remove` | `ctx.remote.agentPresets` in `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`, `copy(from, id, name?)`, `remove(id)` | `copy` and `remove` are direct; `readDocument` combines stored content with metadata from one live discovery. | -| `subagent.interrupt` | `ctx.remote.subagents` in `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | Adapter constructs the internal user-authority variant without resolving or resuming either Agent. | -| `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | `ctx.remote.workspace` in `@deepseek-ai/dsh-workspace` | `snapshot()`, `insertSessionBefore(workspaceId, sessionId, before?)`, `archiveSession(sessionId)` | Registry adapters detach mutable entities and return the settled workspace or archive snapshot. | - -The Remote API deliberately follows Service names rather than preserving dotted legacy names. For example, Session rename becomes `ctx.remote.sessionTitle.rename(...)`. - -## Deferred API Proxy domains - -| Domain | Methods | Reason retained in the API Proxy | -|---|---|---| -| Session Host lifecycle | `session.list`, `search`, `create`, `fork` | Cross-Agent persistence, Workspace assignment, preset composition, and creation policy. | -| Session transcript | `session.history`, `attachment`, `subagent.history` | Cold/live logs, pagination, projections, presenters, and attachment authorization. | -| Agent model selection | `session.models`, `selectModel` | Per-Agent state, model validation, and default persistence are BFF policy. | -| Agent input and control | `session.prompt`, `updateQueue`, `cancel` | Image admission, Inbox mutation, and endpoint-specific live-only semantics. | -| Native settings document | `settings.openDocument` | Host path resolution, document preparation, and native opening remain product policy in API Proxy. | -| Session skill catalog | `skill.list` | Cold Sessions must not resume; preset standing scope and presenter filtering are BFF joins. | -| Host runtime information | `host.describe` | Version, cwd, default model, and attached count combine several Host owners. | -| Host path opening | `host.openPath`, `agentPreset.openDocument` | Native desktop authority and cancellation belong to the Host composition. | -| Remaining preset, subagent, and workspace calls | `agentPreset.list`, `select`; `subagent.list`, `history`, `prompt`; `workspace.create`, `rename`, `delete` | These calls contain roster policy, live/cold joins, authorization, or serialized multi-operation ordering. | -| Stateful and streaming protocol | approvals, questions, responses, mux and Host streams | They are not one-request/one-result business calls. | - -`workspace.delete` stays with `create` and `rename` because all three participate in the same serialized creation/name/delete chain. Splitting one method out would make the Service and API Proxy observe different operation orders. - -## Agent and Session lookup equivalence - -`createApiRemoteAgentResolver()` constructs one resolver and returns it as the API Proxy's `agentFor`. The same closure is installed through `ctx.typert.lookups.configure('agent', ...)`, `ctx.typert.lookups.configure('session', ...)`, and `ctx.typert.contexts.configureHost('agent', ...)`. Therefore a Remote `Agent` or `Session` parameter and a legacy `agentFor()` call share the same live lookup, in-flight resume table, persistence inspection, preset-aware setup, and ownership fence. - -The migration must pin these outcomes with integration tests: - -- a live ordinary Agent is reused without a resume; -- an ordinary cold Session resumes with its persisted header, events, and recorded preset setup; -- concurrent Agent and Session lookups for one id share one resume; -- a live or cold subagent-owned identity fails with `agent-busy` before business invocation; -- an id missing from durable persistence fails with `session-not-found`; -- resolver failures keep their existing `RpcError` through `TypertLookupFailure`. - -Lookup policy is key-wide, not endpoint-specific. Methods such as prompt, queue editing, cancellation, model selection, and skill listing cannot use the shared `agent` or `session` lookup while retaining live-only or no-resume behavior, so they remain in the API Proxy until Typert supports an explicit per-endpoint policy. - -Methods whose signatures contain only branded ids do not invoke Typert object lookup. `subagents.interruptByParent()` must retain the existing process-local Activation lookup and parent-offline behavior: it does not call `agentFor`, read the catalog, inspect persistence, or cold-resume a parent or child. - -## Client and error behavior - -Generated Remote methods return `RemoteResult` values. Client business services adapt them to their current stores and settle successful results immediately exactly as the existing services do, so event frames remain idempotent replays rather than the only update path. The migration preserves domain validation, provider-absence diagnostics, business error codes, structured details, and successful values; only endpoint addressing, the Remote result envelope, and the separately accepted timeout behavior differ from API Proxy transport. - -Resolver-owned `session-not-found` and `agent-busy` errors remain stable because the shared resolver raises `TypertLookupFailure`. Ordinary business exceptions become the Gateway's existing `internal` RPC failure. A selected Client consumer may migrate only if it does not branch on a more specific legacy business error code; if implementation finds such a branch, that RPC leaves this set unless the business package gains a transport-independent typed failure. - -## Browser authentication - -Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Legacy dotted names and Remote slash endpoints therefore use the same process-token-established browser session without an endpoint list. This is a non-escalation requirement: endpoint ownership may change, but an unauthenticated request can reach neither dispatch path. - -## Commit boundaries - -The migration lands as an RFC commit, one vertical commit for each Service, and one final integration commit. A Service commit includes its Host binding and decorators, generated-contract package declarations, API Remotes mount, Client business adoption, and removal of that Service's legacy API Proxy route and production client call. Service commits may be temporarily red because generated artifacts and shared fixtures are reconciled once in the final integration commit. - -The final commit generates every `/remote` artifact from a clean state, updates shared fixtures and tests, moves this note to `implemented`, updates the still-authoritative protocol documentation where central unary ownership changed, and runs the selected repository gates. - -## Alternatives considered - -**Keep simple methods in the central API Proxy.** This preserves one transport facade but continues the duplicated interfaces, schemas, route rows, stubs, and business projections that Typert was introduced to remove. - -**Move every unary API Proxy method.** Unary syntax does not imply single-owner behavior. Session orchestration, live-only control, configuration exposure, and native Host operations would either leak BFF policy into generic Services or create ownerless packages. - -**Give Remote methods a separate resume implementation.** A second resolver could drift on preset restoration, concurrent deduplication, or subagent ownership. Sharing the exact closure with legacy `agentFor()` makes equivalence an implementation fact rather than a promise. - -**Preserve every legacy RPC name and response envelope.** That would turn business packages into copies of the old protocol. Service-oriented names and business values let the Client own joins while Connection continues to own the one RPC envelope. - -**Trust the API Proxy fallback to authenticate requests.** Interceptor selection bypasses that fallback, so Remote methods would become anonymously callable. - -## Acceptance criteria - -- Every migration-table method is callable through its listed `ctx.remote` Service and has no production legacy API Proxy route, schema, map row, client stub, or invocation. -- Existing methods with matching signatures carry `@Remote` directly; every added method performs the adaptation stated in the table and no identity `remote*` wrapper remains. -- Agent/Session integration tests prove the shared lookup outcomes, and subagent interrupt tests prove no cold resume occurs. -- Migrated endpoints reject unauthenticated requests and accept the same valid browser session as legacy endpoints before either dispatch path runs. -- Client behavior and immediate state settlement remain equivalent for every migrated call, including cancellation where supported. -- Deferred methods remain on the API Proxy with their existing behavior. -- A clean generation/build produces and consumes every selected Remote contribution, and focused tests plus final repository gates pass. - -## Risks - -Removing legacy schemas also removes their protocol-specific error taxonomy. A hidden Client branch on one of those codes would make the call non-simple and must be discovered before its Service commit is accepted. - -Generated Remote contracts add build ordering and publication entries to each business package. Missing one runtime mount, declaration export, source-map source, package dependency, or Project Reference can pass a narrow source test while failing a clean Client build. - -Composite dispatch changes security-sensitive carrier code. Tests must exercise both a Remote-owned endpoint and a legacy fallback endpoint so neither path can bypass browser authentication. - -This note applies the existing Typert Remote architecture rather than superseding it. It partially supersedes the central unary ownership and five-step extension checklist in the [GUI RPC protocol note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) and the central wiring inventory in the [Web configuration plane note](../../implemented/architecture/2026-07-30-web-config-plane.md); those notes remain authoritative for Connection envelopes and configuration behavior outside the migrated methods. The title, command, configuration-boundary, subagent-interrupt, and archive notes continue to own their business behavior and require factual transport updates rather than archival. The [browser trust boundary](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.md), [browser authentication](../../implemented/architecture/2026-08-24-browser-token-authentication.md), and [generated-contract build order](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.md) remain authoritative and require no archival action. diff --git a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md deleted file mode 100644 index 92f40fc79f..0000000000 --- a/.agents/notes/proposed/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ /dev/null @@ -1,120 +0,0 @@ -# Agent Note: 将简单的一元 API Proxy 调用迁移到业务 Remote 服务 - -Status: proposed - -[English](2026-08-10-unary-apiproxy-remote-migration.md) | 中文 - -## 问题 - -Host API Proxy 仍承载许多一元方法。这些方法的实现仅执行服务查找、参数投影、一次业务调用和响应投影。尽管 [Typert Remote 调用](../../implemented/architecture/2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包承载此类调用,这种做法仍会在业务服务、API Proxy 接口、Zod schema、路由表、客户端 stub 和 Client 调用方之间重复定义同一约定。 - -仅机械迁移方法并不足够。与 Agent 绑定的 API Proxy 方法会调用 `agentFor()`:它复用 live Agent,使用普通冷 Session 中记录的 preset 恢复该 Session,对并发恢复去重,并拒绝由 subagent 拥有的 identity。如果 Remote 方法以不同方式解析 `Agent` 或 `Session`,即使最终业务调用看起来相同,也会改变生命周期行为。 - -API Proxy 还包含一些不以业务方法为约定的 BFF 操作:Session 生命周期与 transcript(文本记录)组装、模型选择状态、仅限 live 的输入控制、配置过滤、skill(技能)呈现、Host 组合信息和原生桌面操作。有状态交互与流又具有不同的生命周期。若把一元调用的语法一概视为方法简单的依据,就会把产品策略移入任意服务包,或者迫使系统新增没有独立业务所有者的包。 - -最后,Connection 必须在选择 API Proxy 回退路径或 Typert interceptor 前认证请求。若迁移只在回退路径执行认证,由 Remote 持有的 endpoint 就能绕过每个 Host 操作都要求的浏览器身份。 - -## 提案 - -只迁移符合以下条件的一元调用:其业务操作已经有自然归属的服务,且其余适配只是少量参数或结果投影。当现有方法的签名就是预期的消费方约定时,服务应绑定 Typert namespace,并直接使用 `@Remote` 装饰现有方法。只有执行实质性适配时才有理由新增方法;不得添加只做恒等转发的 `remote*` 包装层。 - -`@deepseek-ai/dsh-api-remotes/client` 将挂载所选各业务包生成的 `/remote` 贡献。Client 业务包将调用 `ctx.remote.`,并在包内执行归 Client 所有的关联或呈现投影。对应的 API Proxy 接口成员、schema、路由、处理程序、生成的客户端方法、fixture(测试前置数据)实现和生产调用点,将在该服务的纵向提交中一并移除。 - -大型 BFF 方法仍留在 `dsh-host-apiproxy` 中。如果实现过程中发现某个方法包含端点特有的生命周期策略、大量编排、Client 依赖仅存在于协议层的错误区分,或者其传输数据结构无法用归属方的小型适配器表达,则该方法不在此次迁移范围内。 - -## 迁移集合 - -| 旧 RPC | Remote 目标 | Host 方法 | 适配 | -|---|---|---|---| -| `session.rename` | `ctx.remote.sessionTitle`,位于 `@deepseek-ai/dsh-session-title` | `SessionTitleService.rename(Session, title)` | 直接使用 `@Remote`;Client 将 `eventSeq` 映射到自身的标题投影序列。 | -| `command.list`、`command.execute` | `ctx.remote.commands`,位于 `@deepseek-ai/dsh-commands` | `CommandRuntime.list(Agent)`、`execute(Agent, line, signal)` | 直接使用 `@Remote`;Client 将 `undefined` 映射为未匹配结果,并保留调用方的取消行为。 | -| `llm.providers` | `ctx.remote.llm`,位于 `@deepseek-ai/dsh-llm` | `LlmRuntime.listProviders()`、`listConfigurableProviders()` | 两项读取都直接使用 `@Remote`;Client 关联注册行与配置目录行。 | -| `credentials.describe`、`credentials.set`、`credentials.unset` | `ctx.remote.credentials`,位于 `@deepseek-ai/dsh-api-settings-controller` | `CredentialsController.describe(refs)`、`set(ref, value)`、`unset(ref)` | controller 保留批量上限、引用校验、字段投影、provider 缺失诊断与 provider 拒绝映射,不给抽象 Definition 增加 wire 行为。 | -| `settings.describe`、`settings.update`、`settings.replace`、`settings.mutate` | `ctx.remote.settings`,位于 `@deepseek-ai/dsh-api-settings-controller` | `SettingsController.describe()`、`update(ns, patch, expectedRevision)`、`replace(ns, section, expectedRevision)`、`mutate(ns, ops, expectedRevision)` | controller 保留脱敏、三种写入操作、乐观 revision 校验、provider 缺失诊断与失败 details。 | -| `agentPreset.read`、`agentPreset.copy`、`agentPreset.remove` | `ctx.remote.agentPresets`,位于 `@deepseek-ai/dsh-agent-presets` | `readDocument(id)`、`copy(from, id, name?)`、`remove(id)` | `copy` 和 `remove` 直接暴露现有方法;`readDocument` 将存储的内容与一次实时发现取得的元数据组合。 | -| `subagent.interrupt` | `ctx.remote.subagents`,位于 `@deepseek-ai/dsh-subagent` | `interruptByParent(targetSessionId, parentSessionId)` | 适配器构造内部的用户权限变体,不解析也不恢复任一 Agent。 | -| `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | `ctx.remote.workspace`,位于 `@deepseek-ai/dsh-workspace` | `snapshot()`、`insertSessionBefore(workspaceId, sessionId, before?)`、`archiveSession(sessionId)` | 注册表适配器分离可变实体,并返回已完成更新的 workspace 或归档快照。 | - -Remote API 有意采用服务名称,而不保留旧 RPC 的点分名称。例如,Session 重命名将变为 `ctx.remote.sessionTitle.rename(...)`。 - -## 暂缓迁移的 API Proxy 领域 - -| 领域 | 方法 | 保留在 API Proxy 中的原因 | -|---|---|---| -| Session Host 生命周期 | `session.list`、`search`、`create`、`fork` | 跨 Agent 持久化、Workspace 分配、preset 组合和创建策略。 | -| Session transcript | `session.history`、`attachment`、`subagent.history` | cold/live 日志、分页、投影、呈现器和附件授权。 | -| Agent 模型选择 | `session.models`、`selectModel` | 各 Agent 的状态、模型校验和默认值持久化属于 BFF 策略。 | -| Agent 输入与控制 | `session.prompt`、`updateQueue`、`cancel` | 图片准入、Inbox 变更和端点特有的仅限 live 语义。 | -| 原生 settings 文档 | `settings.openDocument` | Host 路径解析、文档准备和原生打开仍属于 API Proxy 中的产品策略。 | -| Session skill 目录 | `skill.list` | 不得恢复冷 Session;preset 的常驻 scope 和呈现器过滤属于 BFF 关联操作。 | -| Host 运行时信息 | `host.describe` | 版本、cwd、默认模型和当前已附加的 Session 数量来自多个 Host 所有者。 | -| Host 路径打开 | `host.openPath`、`agentPreset.openDocument` | 原生桌面权限和取消属于 Host 组合。 | -| 其余 preset、subagent 和 workspace 调用 | `agentPreset.list`、`select`;`subagent.list`、`history`、`prompt`;`workspace.create`、`rename`、`delete` | 这些调用包含名单策略、live/cold 关联、授权或多项操作的串行执行顺序。 | -| 有状态协议和流式协议 | 审批、问题、响应、mux 和 Host 流 | 它们不是一次请求/一次结果的业务调用。 | - -`workspace.delete` 与 `create` 和 `rename` 保持在一起,因为三者都参与同一条串行的创建/命名/删除操作链。单独迁出一个方法会使服务与 API Proxy 观察到不同的操作顺序。 - -## Agent 与 Session lookup 等价性 - -`createApiRemoteAgentResolver()` 构造一个 resolver,并将其作为 API Proxy 的 `agentFor` 返回。同一个 closure 通过 `ctx.typert.lookups.configure('agent', ...)`、`ctx.typert.lookups.configure('session', ...)` 和 `ctx.typert.contexts.configureHost('agent', ...)` 安装。因此,Remote `Agent` 或 `Session` 参数与旧版 `agentFor()` 调用共享同一套 live lookup、进行中的恢复表、持久化检查、感知 preset 的 setup 和 ownership fence。 - -迁移必须用集成测试固定以下结果: - -- 直接复用普通的 live Agent,不执行恢复; -- 根据持久化的 header、事件和已记录的 preset setup 恢复普通冷 Session; -- 对同一个 id 并发执行 Agent 与 Session lookup 时,共享同一次恢复; -- 无论 live 还是 cold,由 subagent 拥有的 identity 都会在业务调用前以 `agent-busy` 失败; -- 持久化存储中不存在的 id 以 `session-not-found` 失败; -- resolver 失败会保留现有的 `RpcError`,并通过 `TypertLookupFailure` 传递。 - -Lookup 策略作用于整个 key,而非特定端点。提示词输入、队列编辑、取消、模型选择和 skill 列表等方法如果使用共享 `agent` 或 `session` lookup,就无法保留仅限 live 或禁止恢复的行为,因此在 Typert 支持显式的逐端点策略之前,这些方法仍留在 API Proxy 中。 - -签名只包含 branded id 的方法不会调用 Typert 对象 lookup。`subagents.interruptByParent()` 必须保留现有的进程内 Activation lookup 和父级离线行为:它不会调用 `agentFor`、读取目录、检查持久化,也不会冷恢复父 Agent 或子 Agent。 - -## Client 与错误行为 - -生成的 Remote 方法返回 `RemoteResult` 值。Client 业务服务负责把它们适配到现有 store,并与既有服务一样让成功结果立即生效,使事件帧仍是幂等回放,而非唯一的更新路径。迁移保留领域校验、provider 缺失诊断、业务错误码、结构化 details 与成功值;只有 endpoint 寻址、Remote 结果信封和另行接受的超时行为不同于 API Proxy 传输。 - -Resolver 拥有的 `session-not-found` 和 `agent-busy` 错误保持稳定,因为共享 resolver 会抛出 `TypertLookupFailure`。普通业务异常会变成 Gateway 现有的 `internal` RPC 失败。只有在选定的 Client 消费方不根据更具体的旧版业务错误码进行分支时,才能迁移该调用;如果实现过程中发现这种分支,除非业务包新增与传输无关的类型化失败,否则该 RPC 将退出此集合。 - -## 浏览器认证 - -Connection 在选择 Typert interceptor 或 API Proxy 回退路径前认证完整 `/api` 请求。旧式点分名称和 Remote 斜杠 endpoint 因此无需 endpoint 清单,就能使用同一个由进程令牌建立的浏览器会话。这是一条非提权要求:endpoint 所有权可以变化,但未认证请求不能进入任一分发路径。 - -## 提交边界 - -此次迁移将以一个 RFC 提交、每项服务各一个纵向提交,以及一个最终集成提交落地。服务提交包含其 Host 绑定与装饰器、生成约定所需的包声明、API Remotes 挂载、Client 业务接入,以及移除该服务的旧版 API Proxy 路由和生产客户端调用。服务提交可能暂时无法通过门禁,因为生成产物和共享 fixture 将在最终集成提交中统一调整。 - -最终提交从干净状态生成所有 `/remote` 产物,更新共享 fixture 和测试,将本文移至 `implemented`,更新中央一元调用所有权发生变化之处仍具权威性的协议文档,并运行选定的仓库门禁。 - -## 考虑过的替代方案 - -**将简单方法保留在中央 API Proxy 中。** 这会保留统一的传输外观,但仍会延续 Typert 原本要消除的重复接口、schema、路由行、stub 和业务投影。 - -**迁移每一个一元 API Proxy 方法。** 一元调用形式并不表示行为只有一个所有者。Session 编排、仅限 live 的控制、配置暴露和原生 Host 操作要么会把 BFF 策略泄漏到通用服务中,要么会产生没有所有者的包。 - -**为 Remote 方法提供单独的恢复实现。** 第二个 resolver 可能在 preset 恢复、并发去重或 subagent 所有权方面出现偏差。与旧版 `agentFor()` 共享完全相同的 closure,使等价性成为实现事实,而不只是一项承诺。 - -**保留每一个旧版 RPC 名称和响应 envelope。** 这会使业务包变成旧协议的副本。面向服务的名称和业务值让 Client 负责关联操作,而 Connection 继续负责统一的 RPC envelope。 - -**依赖 API Proxy 回退路径认证请求。** interceptor 选择会绕过该回退路径,使 Remote 方法变成匿名可调用。 - -## 验收标准 - -- 迁移表中的每个方法都可通过表中列出的 `ctx.remote` 服务调用,并且不存在生产环境中的旧版 API Proxy 路由、schema、映射表行、客户端 stub 或调用。 -- 签名匹配的现有方法直接带有 `@Remote`;每个新增方法都执行表中所述的适配,且不保留只做恒等转发的 `remote*` 包装层。 -- Agent/Session 集成测试证明共享 lookup 的各项结果,subagent 中断测试证明不会发生冷恢复。 -- 已迁移 endpoint 拒绝未认证请求,并在任一分发路径运行前接受与旧 endpoint 相同的有效浏览器会话。 -- 每项已迁移调用的 Client 行为和立即提交状态的行为保持等价,包括支持取消之处的取消行为。 -- 暂缓迁移的方法及其现有行为仍保留在 API Proxy 上。 -- 一次从干净状态开始的生成与构建会生成并消费所选的每项 Remote 贡献,且聚焦测试和最终仓库门禁均通过。 - -## 风险 - -移除旧版 schema 也会移除其协议特有的错误分类。如果 Client 中存在依赖其中某个错误码的隐蔽分支,该调用就不是简单调用,必须在接受相应服务提交前发现它。 - -生成的 Remote 约定会为每个业务包引入构建顺序要求和发布条目。如果遗漏运行时挂载、声明导出、source map 来源、包依赖或 Project Reference 中的任何一项,局部源码测试可能仍会通过,但从干净状态开始的 Client 构建会失败。 - -复合分发会改变安全敏感的载体代码。测试必须覆盖一个由 Remote 拥有的 endpoint 和一个旧版回退 endpoint,确保两条路径都无法绕过浏览器认证。 - -本文应用现有 Typert Remote 架构,而非取代它。本文部分取代 [GUI RPC 协议笔记](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)中的中央一元调用所有权和五步扩展检查清单,以及 [Web 配置平面笔记](../../implemented/architecture/2026-07-30-web-config-plane.zh.md)中的中央接线清单;对于已迁移方法之外的 Connection envelope 和配置行为,这些笔记仍具权威性。标题、命令、配置边界、subagent 中断和归档笔记继续负责各自的业务行为,只需如实更新传输相关事实,无需归档。[浏览器信任边界](../../implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)、[浏览器认证](../../implemented/architecture/2026-08-24-browser-token-authentication.zh.md)和[生成约定构建顺序](../../implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md)仍具权威性,无需执行归档操作。 diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml index 6644752bc2..d39b5307ea 100644 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -10,3 +10,6 @@ provider: deepseek-official model: deepseek-v4-flash nativeOpen: false +- id: settings-controller + config: + nativeOpen: false diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 5d734d93b8..d5ecdabc70 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -405,11 +405,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { // Read summaries are host-open file links; they also must not open details. const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { ok: true, value: { opened: true as const } }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockResolvedValue({ opened: true }) try { await fileLink.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 8e361d0ff5..76d71f7fb7 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -315,12 +315,8 @@ async function bootPreview(origin: string, browser: Browser): Promise { const exercised = await page.evaluate(async () => { type Result = { result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } } } - interface PreviewApi { - skills: { list(payload: { sessionId: string }): Promise> } - } interface PreviewTransport { fetch(input: string, init: RequestInit): Promise - createApiClient(): PreviewApi } const transport = (globalThis as typeof globalThis & { __DSH_TRANSPORT__?: PreviewTransport }).__DSH_TRANSPORT__ if (transport === undefined) throw new Error('preview transport is absent after boot') @@ -352,14 +348,15 @@ async function bootPreview(origin: string, browser: Browser): Promise { if (!body.result.ok) throw new Error(`${endpoint} failed: ${body.result.error.message}`) return body.result.value } - const api = transport.createApiClient() - const skills = await api.skills.list({ sessionId }) - if (!skills.result.ok) throw new Error(`skill.list failed: ${skills.result.error.message}`) + const skills = await remote<{ skills: Array<{ name: string }> }>( + 'skills/list', { request: { sessionId } }, + ) const createDirectory = async (path: string, name: string): Promise => { await remote('directoryPicker/createDirectory', { path, name }) await new Promise((resolve) => { setTimeout(resolve, 250) }) - const refreshed = await api.skills.list({ sessionId }) - if (!refreshed.result.ok) throw new Error(`skill.list refresh failed: ${refreshed.result.error.message}`) + await remote<{ skills: Array<{ name: string }> }>( + 'skills/list', { request: { sessionId } }, + ) } await createDirectory('/dsh/workspace/.agents/skills', 'runtime-created') // Settings and credentials both answer over the Remote carrier, so this @@ -383,7 +380,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { await remote('credentials/unset', { ref: 'PREVIEW_TEST_SECRET' }) await new Promise((resolve) => { setTimeout(resolve, 250) }) return { - skillCount: skills.result.value.skills.length, + skillCount: skills.skills.length, credentialConfigured: credentials.PREVIEW_TEST_SECRET?.configured, } }) diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index 81259cf365..cdc3296693 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -149,19 +149,16 @@ describe('web e2e: a finished turn ends with the files it produced', () => { expect(await showFolder.count()).toBe(1) expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { ok: true, value: { opened: true as const } }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockResolvedValue({ opened: true }) try { const [response] = await Promise.all([ - page.waitForResponse(response => new URL(response.url()).pathname === '/api/host.openPath'), + page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/openWorkspacePath'), showFolder.click({ clickCount: 1 }), ]) expect(response.status()).toBe(200) expect(openPath).toHaveBeenCalledTimes(1) - expect(openPath.mock.calls[0]![0].payload).toEqual({ path: `${scaffold.workspaceCwd}/.` }) + expect(openPath.mock.calls[0]![0]).toMatchObject({ path: '.' }) } finally { openPath.mockRestore() } diff --git a/apps/web/tests/produced-files.overlay.yml b/apps/web/tests/produced-files.overlay.yml index 0afe201f71..ceac487918 100644 --- a/apps/web/tests/produced-files.overlay.yml +++ b/apps/web/tests/produced-files.overlay.yml @@ -4,3 +4,6 @@ - id: api-gateway config: nativeOpen: true +- id: settings-controller + config: + nativeOpen: true diff --git a/apps/web/tests/scaffold-hermetic.e2e.ts b/apps/web/tests/scaffold-hermetic.e2e.ts index c504913b9b..585d10ab98 100644 --- a/apps/web/tests/scaffold-hermetic.e2e.ts +++ b/apps/web/tests/scaffold-hermetic.e2e.ts @@ -42,7 +42,7 @@ it('isolates replay skill discovery from every ambient host root', async () => { const ctx = scaffold.ctx // Local skill discovery belongs to the agent's preset LAYER of the host // registry, so the roots under test are only reachable through a composed - // agent's view — the same scope the gateway's `skill.list` resolves for a + // agent's view — the same scope the `skills/list` Remote resolves for a // browser request about a session. const handle = await ctx.agents.create({ sessionId: SessionId('hermetic-skills'), diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 44d6a10254..368beeb9e1 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -417,11 +417,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await fileLink.waitFor({ timeout: 10_000 }) const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { ok: true, value: { opened: true as const } }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockResolvedValue({ opened: true }) try { await fileLink.click() await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).toBe('true') @@ -436,14 +433,8 @@ describe('web e2e: seeded history renders through cold resume', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-file-open-failure')) const fileLink = page.locator('[data-variant="read"] button').first() await fileLink.waitFor({ timeout: 10_000 }) - const openPath = vi.spyOn(scaffold.ctx.apiProxy.host, 'openPath') - .mockImplementation(async (request, _signal) => ({ - rpcId: request.rpcId, - result: { - ok: false as const, - error: { code: 'internal', message: 'xdg-open is not available', details: {} }, - }, - })) + const openPath = vi.spyOn(scaffold.ctx.sessionController, 'openWorkspacePath') + .mockRejectedValue(new Error('xdg-open is not available')) try { await fileLink.click() const dialog = page.getByRole('dialog', { name: 'Couldn’t open file' }) @@ -454,7 +445,7 @@ describe('web e2e: seeded history renders through cold resume', () => { .toContain('path open failed: xdg-open is not available') await page.getByRole('button', { name: 'Retry' }).click() await expect.poll(() => openPath.mock.calls.length, { timeout: 5_000 }).toBe(2) - expect(openPath.mock.calls[0]![0].payload).toEqual(openPath.mock.calls[1]![0].payload) + expect(openPath.mock.calls[0]![0]).toEqual(openPath.mock.calls[1]![0]) await page.getByRole('button', { name: 'Cancel' }).click() await expect.poll(() => page.getByRole('dialog', { name: 'Couldn’t open file' }).count(), { timeout: 5_000, diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 695dc36d4e..96673df5f8 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -68,12 +68,12 @@ describe('web e2e: settings modal and General preferences', () => { const openDocument = dialog.getByRole('button', { name: '打开配置文件' }) await openDocument.waitFor({ timeout: 10_000 }) let openRequests = 0 - await page.route('**/api/settings.openDocument', async (route) => { + await page.route('**/api/settings/openSettingsDocument', async (route) => { const envelope = route.request().postDataJSON() as { rpcId: string - payload: Record + payload: { args: Record } } - expect(envelope.payload).toEqual({}) + expect(envelope.payload).toEqual({ args: {} }) openRequests += 1 await route.fulfill({ status: 200, @@ -88,7 +88,7 @@ describe('web e2e: settings modal and General preferences', () => { await openDocument.click() await expect.poll(() => openRequests, { timeout: 5_000 }).toBe(1) await expect.poll(() => openDocument.isEnabled(), { timeout: 5_000 }).toBe(true) - await page.unroute('**/api/settings.openDocument') + await page.unroute('**/api/settings/openSettingsDocument') // Golden of the freshly opened dialog (default zh, General active). const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(DIALOG_EXPECTED, snapshot, MODE) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4a4d691ac6..4033b29587 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 3886ca582ea934c51fc20dfec01fd9f2af829597 -capability-seams.zh.md: a79b93bb8d6fff36e0828dbba8f7e20b885bcbfe +capability-seams.md: 4e9109294c3b02476af9bf97ecf280b1ea84b128 +capability-seams.zh.md: a4d6dd1b7d4111736d532aa36a4b806aef7d8dc0 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 3886ca582e..4e9109294c 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,8 @@ flowchart LR pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
    Host Session Remote controller"] + svc_sessionFileReferences["ctx.sessionFileReferences
    Session-addressed file-reference Remote adapter"] + svc_sessionSkillCatalog["ctx.sessionSkillCatalog
    Session-addressed skill Remote adapter"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
    Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
    Host settings-surface Remote controller"] @@ -223,6 +225,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_session_controller --> svc_sessionFileReferences + pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController @@ -362,6 +366,7 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b + svc_fileReferences --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -379,7 +384,6 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash - svc_sessionController --> pkg_host_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -467,7 +471,9 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | -| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains. | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | +| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | +| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. | @@ -486,7 +492,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | -| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents. | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents. | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a79b93bb8d..a4d6dd1b7d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -40,6 +40,8 @@ flowchart LR pkg_invariants["invariants"] pkg_message_feedback["message-feedback"] svc_sessionController["ctx.sessionController
    Host Session Remote controller"] + svc_sessionFileReferences["ctx.sessionFileReferences
    Session-addressed file-reference Remote adapter"] + svc_sessionSkillCatalog["ctx.sessionSkillCatalog
    Session-addressed skill Remote adapter"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
    Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
    Host settings-surface Remote controller"] @@ -225,6 +227,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_api_session_controller --> svc_sessionController + pkg_api_session_controller --> svc_sessionFileReferences + pkg_api_session_controller --> svc_sessionSkillCatalog pkg_api_settings_controller --> svc_credentialsController pkg_api_settings_controller --> svc_settingsController pkg_api_workspace_controller --> svc_directoryPickerController @@ -364,6 +368,7 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_e2b --> pkg_fs_e2b svc_e2b --> pkg_subprocess_e2b + svc_fileReferences --> pkg_api_session_controller svc_fs --> pkg_tool_fs svc_invariants --> pkg_agent svc_invariants --> pkg_agent_loop @@ -381,7 +386,6 @@ flowchart LR svc_sandboxPolicy --> pkg_bash_sandbox svc_sandboxPolicy --> pkg_fs_sandbox svc_sandboxPolicy --> pkg_terminal_bash - svc_sessionController --> pkg_host_apiproxy svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude_code svc_sessionPersistence --> pkg_hooks_codex @@ -469,7 +473,9 @@ flowchart LR | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPruner` | `core` | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | -| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态与 Agent 激活策略;apiProxy 在需要 Session 上下文的领域中复用其检查和 Agent 解析操作。 | +| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | +| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | +| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 | @@ -488,7 +494,7 @@ flowchart LR | `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspaceRegistry` | `core` | [`workspace`](../packages/workspace/workspace) | - | [`api-workspace-controller`](../packages/api/workspace-controller), [`api-session-controller`](../packages/api/session-controller) | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | -| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | - | - | 该接口通过其一元 Remote 契约返回指定 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问和排序,但不会读取文件内容。 | +| `ctx.fileReferences` | `seam` | [`file-reference`](../packages/context/file-reference) | [`file-reference-local`](../packages/context/file-reference-local) | [`api-session-controller`](../packages/api/session-controller) | - | 该接口返回 Agent cwd 内仅含路径的补全候选;提供方负责命名空间访问与排序,但不读取文件内容。 | | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 638000d0ef..2416c74af9 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 4331c0a5153f32f0e6af5b6ec6fd182ee9b335b4 -config-catalog.zh.md: 54f6ddde10053d422af2c5ebfd88a367597adc89 +config-catalog.md: 0a08455a27fa94f1bb69628b61dd8eb23d318ded +config-catalog.zh.md: ad6a9c0481750ec41c0701d3a9e3989fd0e9ebf4 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4331c0a515..0a08455a27 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -304,7 +304,21 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) + + + +## `@deepseek-ai/dsh-api-settings-controller` + +```ts config-catalog +/** Native document-opening policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} +``` + +Source: [`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) @@ -858,7 +872,7 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController` +Requires: `agentDefaultModel` · `agents` · `attachments` · `sessions` · `sessionQuery` ```ts config-catalog /** Gateway plugin configuration. */ @@ -880,7 +894,7 @@ export interface Config { } ``` -Source: [`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:40`](../packages/host/apiproxy/src/index.ts) @@ -3399,7 +3413,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-acp-app` — requires `cmdlineArgs` ([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent` ([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — requires `typertGateway` ([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-settings-controller` ([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — requires `typert` · `workspaceRegistry` ([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 54f6ddde10..ad6a9c0481 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -306,7 +306,21 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:58`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) + + + +## `@deepseek-ai/dsh-api-settings-controller` + +```ts config-catalog +/** Native document-opening policy. */ +export interface Config { + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean +} +``` + +来源:[`packages/api/settings-controller/src/index.ts:41`](../packages/api/settings-controller/src/index.ts) @@ -860,7 +874,7 @@ export interface Config { ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `sessionQuery` · `sessionController` +需要:`agentDefaultModel` · `agents` · `attachments` · `sessions` · `sessionQuery` ```ts config-catalog /** Gateway plugin configuration. */ @@ -882,7 +896,7 @@ export interface Config { } ``` -来源:[`packages/host/apiproxy/src/index.ts:42`](../packages/host/apiproxy/src/index.ts) +来源:[`packages/host/apiproxy/src/index.ts:40`](../packages/host/apiproxy/src/index.ts) @@ -3401,7 +3415,6 @@ export interface Config { - `@deepseek-ai/dsh-acp-app` — 需要 `cmdlineArgs`([`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts)) - `@deepseek-ai/dsh-agent`([`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts)) - `@deepseek-ai/dsh-api-remotes` — 需要 `typertGateway`([`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts)) -- `@deepseek-ai/dsh-api-settings-controller`([`packages/api/settings-controller/src/index.ts`](../packages/api/settings-controller/src/index.ts)) - `@deepseek-ai/dsh-api-workspace-controller` — 需要 `typert` · `workspaceRegistry`([`packages/api/workspace-controller/src/index.ts`](../packages/api/workspace-controller/src/index.ts)) - `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts)) - `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index e5cf853414..468327d243 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: e849cb84265c0781e4a8680d0bb247e9955b5c2e -event-producer-consumer.zh.md: b5835c56b26f3a75fd792d3a71c3ab2dc688ea42 +event-producer-consumer.md: 2c443095b796eb274fa099b4b7d91b4454311a37 +event-producer-consumer.zh.md: b35e1c56d7f51d3aed7f3f81aec228708cb952e3 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index e849cb8426..2c443095b7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:504`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:484`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:511`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:490`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:497`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:538`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:518`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:545`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:524`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -43,7 +43,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b5835c56b2..b35e1c56d7 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:504`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:484`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:511`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:490`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:497`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:538`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:518`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:545`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:524`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -45,7 +45,7 @@ | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:66`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:64`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index c69c9d86b9..73c0c75403 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: a6f70aa1ff1616acf305ae386b148c56d8ebcf85 -module-graph.zh.md: 3b33bc68e67c19c03ec4d212c864371b27b31278 +module-graph.md: e0376d865fac1505cce48f4f3a678a11730ecd0e +module-graph.zh.md: 72af3b9a52764e0ae8ed2b7cef910eeedefa6c6c diff --git a/docs/module-graph.md b/docs/module-graph.md index a6f70aa1ff..e0376d865f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -384,6 +384,7 @@ flowchart TD pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants + pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -427,6 +428,7 @@ flowchart TD pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout + pkg_llm --> pkg_typert_protocol pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_attachment_local --> pkg_invariants @@ -441,6 +443,10 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -539,14 +545,8 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants - pkg_api_settings_controller --> pkg_session - pkg_api_settings_controller --> pkg_settings - pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants - pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -1011,9 +1011,27 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval + pkg_api_settings_controller --> pkg_agent_presets + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_native_command + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_credentials + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings + pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1027,8 +1045,6 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1090,17 +1106,11 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_directory_picker - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings - pkg_client_connection --> pkg_tool_todo + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1142,10 +1152,9 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_invariants @@ -1205,11 +1214,39 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_api_session_controller --> pkg_agent + pkg_api_session_controller --> pkg_agent_default_model + pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_api_gateway + pkg_api_session_controller --> pkg_attachment + pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_invariants + pkg_api_session_controller --> pkg_jobs + pkg_api_session_controller --> pkg_llm + pkg_api_session_controller --> pkg_native_command + pkg_api_session_controller --> pkg_scope + pkg_api_session_controller --> pkg_session + pkg_api_session_controller --> pkg_session_persistence + pkg_api_session_controller --> pkg_session_projection + pkg_api_session_controller --> pkg_session_projection_cache + pkg_api_session_controller --> pkg_session_query + pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_skill + pkg_api_session_controller --> pkg_subagent + pkg_api_session_controller --> pkg_typert_protocol + pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_workspace_path + pkg_api_session_controller --> pkg_workspace + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1218,9 +1255,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1248,36 +1282,30 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_session_controller --> pkg_agent - pkg_api_session_controller --> pkg_agent_default_model - pkg_api_session_controller --> pkg_agent_presets - pkg_api_session_controller --> pkg_api_gateway - pkg_api_session_controller --> pkg_attachment - pkg_api_session_controller --> pkg_brand - pkg_api_session_controller --> pkg_client_connection - pkg_api_session_controller --> pkg_invariants - pkg_api_session_controller --> pkg_jobs - pkg_api_session_controller --> pkg_llm - pkg_api_session_controller --> pkg_scope - pkg_api_session_controller --> pkg_session - pkg_api_session_controller --> pkg_session_persistence - pkg_api_session_controller --> pkg_session_projection - pkg_api_session_controller --> pkg_session_projection_cache - pkg_api_session_controller --> pkg_session_query - pkg_api_session_controller --> pkg_session_title - pkg_api_session_controller --> pkg_subagent - pkg_api_session_controller --> pkg_typert_protocol - pkg_api_session_controller --> pkg_typert_registry - pkg_api_session_controller --> pkg_util_workspace_path - pkg_api_session_controller --> pkg_workspace - pkg_api_workspace_controller --> pkg_api_gateway - pkg_api_workspace_controller --> pkg_client_connection - pkg_api_workspace_controller --> pkg_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants - pkg_api_workspace_controller --> pkg_session - pkg_api_workspace_controller --> pkg_storage_domain - pkg_api_workspace_controller --> pkg_typert_protocol - pkg_api_workspace_controller --> pkg_workspace + pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller + pkg_api_remotes --> pkg_api_workspace_controller + pkg_api_remotes --> pkg_commands + pkg_api_remotes --> pkg_cordis_host_runner + pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_llm + pkg_api_remotes --> pkg_message_feedback + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_reference + pkg_api_remotes --> pkg_settings + pkg_api_remotes --> pkg_subagent + pkg_api_remotes --> pkg_user_approval + pkg_api_remotes --> pkg_user_questions + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1304,30 +1332,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_api_remotes --> pkg_agent_presets - pkg_api_remotes --> pkg_api_gateway - pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller - pkg_api_remotes --> pkg_api_workspace_controller - pkg_api_remotes --> pkg_commands - pkg_api_remotes --> pkg_cordis_host_runner - pkg_api_remotes --> pkg_credentials - pkg_api_remotes --> pkg_file_reference - pkg_api_remotes --> pkg_goal - pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants - pkg_api_remotes --> pkg_llm - pkg_api_remotes --> pkg_message_feedback - pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_reference - pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_subagent - pkg_api_remotes --> pkg_user_approval - pkg_api_remotes --> pkg_user_questions - pkg_client_ui_session --> pkg_api_session_controller - pkg_client_ui_session --> pkg_client_ui_renderer - pkg_client_ui_session --> pkg_invariants - pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants @@ -1339,7 +1343,6 @@ flowchart TD pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings pkg_client_ui_settings_models --> pkg_api_remotes - pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings @@ -1541,7 +1544,6 @@ flowchart TD pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller pkg_client_ui_commands --> pkg_client_locale @@ -1625,7 +1627,6 @@ flowchart TD pkg_client_ui_message_feedback --> pkg_typert_protocol pkg_client_ui_model_selection --> pkg_api_remotes pkg_client_ui_model_selection --> pkg_api_session_controller - pkg_client_ui_model_selection --> pkg_client_connection pkg_client_ui_model_selection --> pkg_client_locale pkg_client_ui_model_selection --> pkg_client_ui_commands pkg_client_ui_model_selection --> pkg_client_ui_conversation @@ -1730,6 +1731,7 @@ flowchart TD | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1753,11 +1755,12 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1785,8 +1788,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1870,21 +1872,22 @@ flowchart TD | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1894,25 +1897,23 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1933,7 +1934,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | @@ -1943,7 +1944,7 @@ flowchart TD | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 3b33bc68e6..72af3b9a52 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -386,6 +386,7 @@ flowchart TD pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants + pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -429,6 +430,7 @@ flowchart TD pkg_llm --> pkg_brand pkg_llm --> pkg_invariants pkg_llm --> pkg_timeout + pkg_llm --> pkg_typert_protocol pkg_attachment_local --> pkg_attachment pkg_attachment_local --> pkg_home_paths pkg_attachment_local --> pkg_invariants @@ -443,6 +445,10 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_apiproxy + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -541,14 +547,8 @@ flowchart TD pkg_web_search_deepseek --> pkg_web pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_api_settings_controller --> pkg_credentials - pkg_api_settings_controller --> pkg_invariants - pkg_api_settings_controller --> pkg_session - pkg_api_settings_controller --> pkg_settings - pkg_api_settings_controller --> pkg_typert_protocol pkg_file_reference --> pkg_agent pkg_file_reference --> pkg_invariants - pkg_file_reference --> pkg_typert_protocol pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session @@ -1013,9 +1013,27 @@ flowchart TD pkg_acp --> pkg_session_persistence pkg_acp --> pkg_token_meter pkg_acp --> pkg_user_approval + pkg_api_settings_controller --> pkg_agent_presets + pkg_api_settings_controller --> pkg_credentials + pkg_api_settings_controller --> pkg_invariants + pkg_api_settings_controller --> pkg_native_command + pkg_api_settings_controller --> pkg_session + pkg_api_settings_controller --> pkg_settings + pkg_api_settings_controller --> pkg_typert_protocol pkg_web_app --> pkg_invariants pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt + pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_commands + pkg_client_connection --> pkg_credentials + pkg_client_connection --> pkg_host_apiproxy + pkg_client_connection --> pkg_host_directory_picker + pkg_client_connection --> pkg_host_webserver + pkg_client_connection --> pkg_invariants + pkg_client_connection --> pkg_llm + pkg_client_connection --> pkg_session + pkg_client_connection --> pkg_settings + pkg_client_connection --> pkg_tool_todo pkg_compaction_tool_result_pruner --> pkg_compaction pkg_compaction_tool_result_pruner --> pkg_invariants pkg_compaction_tool_result_pruner --> pkg_llm @@ -1029,8 +1047,6 @@ flowchart TD pkg_tool_cordis --> pkg_session pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants pkg_tool_bash --> pkg_agent pkg_tool_bash --> pkg_invariants pkg_tool_bash --> pkg_jobs @@ -1092,17 +1108,11 @@ flowchart TD pkg_tool_session_query --> pkg_system_prompt pkg_tool_session_query --> pkg_timeout pkg_tool_session_query --> pkg_tools - pkg_client_connection --> pkg_attachment - pkg_client_connection --> pkg_commands - pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy - pkg_client_connection --> pkg_host_directory_picker - pkg_client_connection --> pkg_host_webserver - pkg_client_connection --> pkg_invariants - pkg_client_connection --> pkg_llm - pkg_client_connection --> pkg_session - pkg_client_connection --> pkg_settings - pkg_client_connection --> pkg_tool_todo + pkg_api_gateway --> pkg_brand + pkg_api_gateway --> pkg_client_connection + pkg_api_gateway --> pkg_host_webserver + pkg_api_gateway --> pkg_invariants + pkg_api_gateway --> pkg_typert_registry pkg_compaction_basic --> pkg_agent pkg_compaction_basic --> pkg_commands pkg_compaction_basic --> pkg_compaction @@ -1144,10 +1154,9 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants + pkg_host_frontend_static --> pkg_client_connection + pkg_host_frontend_static --> pkg_host_webserver + pkg_host_frontend_static --> pkg_invariants pkg_webhook_github --> pkg_credentials pkg_webhook_github --> pkg_host_webserver pkg_webhook_github --> pkg_invariants @@ -1207,11 +1216,39 @@ flowchart TD pkg_hooks_claude_code --> pkg_session_persistence pkg_hooks_claude_code --> pkg_subagent pkg_hooks_claude_code --> pkg_tools - pkg_api_gateway --> pkg_brand - pkg_api_gateway --> pkg_client_connection - pkg_api_gateway --> pkg_host_webserver - pkg_api_gateway --> pkg_invariants - pkg_api_gateway --> pkg_typert_registry + pkg_api_session_controller --> pkg_agent + pkg_api_session_controller --> pkg_agent_default_model + pkg_api_session_controller --> pkg_agent_presets + pkg_api_session_controller --> pkg_api_gateway + pkg_api_session_controller --> pkg_attachment + pkg_api_session_controller --> pkg_brand + pkg_api_session_controller --> pkg_client_connection + pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_invariants + pkg_api_session_controller --> pkg_jobs + pkg_api_session_controller --> pkg_llm + pkg_api_session_controller --> pkg_native_command + pkg_api_session_controller --> pkg_scope + pkg_api_session_controller --> pkg_session + pkg_api_session_controller --> pkg_session_persistence + pkg_api_session_controller --> pkg_session_projection + pkg_api_session_controller --> pkg_session_projection_cache + pkg_api_session_controller --> pkg_session_query + pkg_api_session_controller --> pkg_session_title + pkg_api_session_controller --> pkg_skill + pkg_api_session_controller --> pkg_subagent + pkg_api_session_controller --> pkg_typert_protocol + pkg_api_session_controller --> pkg_typert_registry + pkg_api_session_controller --> pkg_util_workspace_path + pkg_api_session_controller --> pkg_workspace + pkg_api_workspace_controller --> pkg_api_gateway + pkg_api_workspace_controller --> pkg_client_connection + pkg_api_workspace_controller --> pkg_host_directory_picker + pkg_api_workspace_controller --> pkg_invariants + pkg_api_workspace_controller --> pkg_session + pkg_api_workspace_controller --> pkg_storage_domain + pkg_api_workspace_controller --> pkg_typert_protocol + pkg_api_workspace_controller --> pkg_workspace pkg_experimental_agent_team --> pkg_agent pkg_experimental_agent_team --> pkg_brand pkg_experimental_agent_team --> pkg_invariants @@ -1220,9 +1257,6 @@ flowchart TD pkg_experimental_agent_team --> pkg_session_persistence pkg_experimental_agent_team --> pkg_subagent pkg_experimental_agent_team --> pkg_typert_protocol - pkg_host_frontend_static --> pkg_client_connection - pkg_host_frontend_static --> pkg_host_webserver - pkg_host_frontend_static --> pkg_invariants pkg_sdk_protocol --> pkg_invariants pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session @@ -1250,36 +1284,30 @@ flowchart TD pkg_subagent_spawn_in_process --> pkg_invariants pkg_subagent_spawn_in_process --> pkg_subagent pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver - pkg_api_session_controller --> pkg_agent - pkg_api_session_controller --> pkg_agent_default_model - pkg_api_session_controller --> pkg_agent_presets - pkg_api_session_controller --> pkg_api_gateway - pkg_api_session_controller --> pkg_attachment - pkg_api_session_controller --> pkg_brand - pkg_api_session_controller --> pkg_client_connection - pkg_api_session_controller --> pkg_invariants - pkg_api_session_controller --> pkg_jobs - pkg_api_session_controller --> pkg_llm - pkg_api_session_controller --> pkg_scope - pkg_api_session_controller --> pkg_session - pkg_api_session_controller --> pkg_session_persistence - pkg_api_session_controller --> pkg_session_projection - pkg_api_session_controller --> pkg_session_projection_cache - pkg_api_session_controller --> pkg_session_query - pkg_api_session_controller --> pkg_session_title - pkg_api_session_controller --> pkg_subagent - pkg_api_session_controller --> pkg_typert_protocol - pkg_api_session_controller --> pkg_typert_registry - pkg_api_session_controller --> pkg_util_workspace_path - pkg_api_session_controller --> pkg_workspace - pkg_api_workspace_controller --> pkg_api_gateway - pkg_api_workspace_controller --> pkg_client_connection - pkg_api_workspace_controller --> pkg_host_directory_picker - pkg_api_workspace_controller --> pkg_invariants - pkg_api_workspace_controller --> pkg_session - pkg_api_workspace_controller --> pkg_storage_domain - pkg_api_workspace_controller --> pkg_typert_protocol - pkg_api_workspace_controller --> pkg_workspace + pkg_api_remotes --> pkg_agent_presets + pkg_api_remotes --> pkg_api_gateway + pkg_api_remotes --> pkg_api_session_controller + pkg_api_remotes --> pkg_api_settings_controller + pkg_api_remotes --> pkg_api_workspace_controller + pkg_api_remotes --> pkg_commands + pkg_api_remotes --> pkg_cordis_host_runner + pkg_api_remotes --> pkg_credentials + pkg_api_remotes --> pkg_file_reference + pkg_api_remotes --> pkg_goal + pkg_api_remotes --> pkg_host_plugin_inventory + pkg_api_remotes --> pkg_invariants + pkg_api_remotes --> pkg_llm + pkg_api_remotes --> pkg_message_feedback + pkg_api_remotes --> pkg_session + pkg_api_remotes --> pkg_session_reference + pkg_api_remotes --> pkg_settings + pkg_api_remotes --> pkg_subagent + pkg_api_remotes --> pkg_user_approval + pkg_api_remotes --> pkg_user_questions + pkg_client_ui_session --> pkg_api_session_controller + pkg_client_ui_session --> pkg_client_ui_renderer + pkg_client_ui_session --> pkg_invariants + pkg_client_ui_session --> pkg_session pkg_experimental_tool_agent_team --> pkg_agent pkg_experimental_tool_agent_team --> pkg_experimental_agent_team pkg_experimental_tool_agent_team --> pkg_invariants @@ -1306,30 +1334,6 @@ flowchart TD pkg_subagent_dsh_sdk --> pkg_session pkg_subagent_dsh_sdk --> pkg_subagent pkg_subagent_dsh_sdk --> pkg_subprocess - pkg_api_remotes --> pkg_agent_presets - pkg_api_remotes --> pkg_api_gateway - pkg_api_remotes --> pkg_api_session_controller - pkg_api_remotes --> pkg_api_settings_controller - pkg_api_remotes --> pkg_api_workspace_controller - pkg_api_remotes --> pkg_commands - pkg_api_remotes --> pkg_cordis_host_runner - pkg_api_remotes --> pkg_credentials - pkg_api_remotes --> pkg_file_reference - pkg_api_remotes --> pkg_goal - pkg_api_remotes --> pkg_host_plugin_inventory - pkg_api_remotes --> pkg_invariants - pkg_api_remotes --> pkg_llm - pkg_api_remotes --> pkg_message_feedback - pkg_api_remotes --> pkg_session - pkg_api_remotes --> pkg_session_reference - pkg_api_remotes --> pkg_settings - pkg_api_remotes --> pkg_subagent - pkg_api_remotes --> pkg_user_approval - pkg_api_remotes --> pkg_user_questions - pkg_client_ui_session --> pkg_api_session_controller - pkg_client_ui_session --> pkg_client_ui_renderer - pkg_client_ui_session --> pkg_invariants - pkg_client_ui_session --> pkg_session pkg_client_ui_settings --> pkg_api_remotes pkg_client_ui_settings --> pkg_client_connection pkg_client_ui_settings --> pkg_invariants @@ -1341,7 +1345,6 @@ flowchart TD pkg_client_locale --> pkg_invariants pkg_client_locale --> pkg_settings pkg_client_ui_settings_models --> pkg_api_remotes - pkg_client_ui_settings_models --> pkg_client_connection pkg_client_ui_settings_models --> pkg_client_locale pkg_client_ui_settings_models --> pkg_client_ui_renderer pkg_client_ui_settings_models --> pkg_client_ui_settings @@ -1543,7 +1546,6 @@ flowchart TD pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller pkg_client_ui_commands --> pkg_client_locale @@ -1627,7 +1629,6 @@ flowchart TD pkg_client_ui_message_feedback --> pkg_typert_protocol pkg_client_ui_model_selection --> pkg_api_remotes pkg_client_ui_model_selection --> pkg_api_session_controller - pkg_client_ui_model_selection --> pkg_client_connection pkg_client_ui_model_selection --> pkg_client_locale pkg_client_ui_model_selection --> pkg_client_ui_commands pkg_client_ui_model_selection --> pkg_client_ui_conversation @@ -1732,6 +1733,7 @@ flowchart TD | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1755,11 +1757,12 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1787,8 +1790,7 @@ flowchart TD | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | -| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | -| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | +| [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1872,21 +1874,22 @@ flowchart TD | [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) | | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | +| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) | | [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval) | | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1896,25 +1899,23 @@ flowchart TD | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-gateway`](../packages/api/gateway) | `api` | [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | -| [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | -| [`api-workspace-controller`](../packages/api/workspace-controller) | `api` | [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`host-directory-picker`](../packages/host/directory-picker), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | +| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | -| [`api-remotes`](../packages/api/remotes) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-settings-controller`](../packages/api/settings-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`commands`](../packages/interaction/commands), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`goal`](../packages/goal/goal), [`host-plugin-inventory`](../packages/host/plugin-inventory), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-reference`](../packages/context/session-reference), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-session`](../packages/client/ui-session) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`client-locale`](../packages/client/locale) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-settings-models`](../packages/client/ui-settings-models) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugin-inventory`](../packages/client/ui-settings-plugin-inventory) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-settings-plugins`](../packages/client/ui-settings-plugins) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-settings`](../packages/client/ui-settings), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | @@ -1935,7 +1936,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | @@ -1945,7 +1946,7 @@ flowchart TD | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | +| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-permission-presets`](../packages/client/ui-permission-presets) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`invariants`](../packages/runtime-diagnostics/invariants), [`permission-presets`](../packages/interaction/permission-presets) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`api-remotes`](../packages/api/remotes), [`api-workspace-controller`](../packages/api/workspace-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-workflow-run`](../packages/client/ui-workflow-run) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 7b91ec4729..19974d41f8 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 331c30e1039741462df7ae3e7a9e6497281a5df0 -llm-streaming.zh.md: f05c67bc7552a4bc5a9359416d98b62f238f1c65 +llm-streaming.md: e37356d91e75334987b6fc707744f2376df1b2fc +llm-streaming.zh.md: 05f24bed6beff9508db88e60af7b259eda33f474 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 331c30e103..e37356d91e 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -659,8 +659,6 @@ interface LlmModelDiscoveryRequest { api?: string /** Credential for this interrogation alone; the harness never stores it. */ apiKey?: string - /** Caller cancellation; implementations must settle promptly after it aborts. */ - signal?: AbortSignal } ``` @@ -883,7 +881,7 @@ registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHa * Describe provider routes with a registered adapter. * @returns detached provider metadata in registration order. */ -listProviders(): LlmProviderInfo[] +@Remote listProviders(): LlmProviderInfo[] /** * Declare provider routes an adapter plugin can activate through @@ -899,7 +897,7 @@ registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): Dire * List every declared configurable provider, registered or dormant. * @returns detached directory entries in declaration order. */ -listConfigurableProviders(): LlmConfigurableProvider[] +@Remote listConfigurableProviders(): LlmConfigurableProvider[] /** * Offer to interrogate provider endpoints on behalf of the settings @@ -908,10 +906,10 @@ listConfigurableProviders(): LlmConfigurableProvider[] * directory, and because a provider being *added* has no route to name yet. * Disposed with the fiber. * @param settingsNs - the namespace whose profiles this discovery serves. - * @param discover - interrogates one endpoint; must honor `request.signal`. + * @param discover - interrogates one endpoint and must honor the supplied signal. * @returns the disposer that withdraws the offer. */ -registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void +registerModelDiscovery( settingsNs: string, discover: ( request: LlmModelDiscoveryRequest, signal?: AbortSignal, ) => Promise, ): () => void /** * Interrogate one provider endpoint for the models it advertises. The @@ -920,9 +918,20 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover * candidate metadata a surface may offer for adoption. * @param settingsNs - namespace whose registered discovery serves this draft. * @param request - the endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation. * @returns the advertised models, deduplicated in endpoint order. */ -async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise +async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal, ): Promise + +/** + * Remote adapter for one draft provider interrogation. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation supplied by the Remote carrier. + * @returns advertised models in endpoint order. + * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + */ +@Remote('discoverModels') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise /** * Resolve the retry policy captured when one provider route was registered. diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index f05c67bc75..05f24bed6b 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -665,8 +665,6 @@ interface LlmModelDiscoveryRequest { api?: string /** Credential for this interrogation alone; the harness never stores it. */ apiKey?: string - /** Caller cancellation; implementations must settle promptly after it aborts. */ - signal?: AbortSignal } ``` @@ -889,7 +887,7 @@ registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHa * Describe provider routes with a registered adapter. * @returns detached provider metadata in registration order. */ -listProviders(): LlmProviderInfo[] +@Remote listProviders(): LlmProviderInfo[] /** * Declare provider routes an adapter plugin can activate through @@ -905,7 +903,7 @@ registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): Dire * List every declared configurable provider, registered or dormant. * @returns detached directory entries in declaration order. */ -listConfigurableProviders(): LlmConfigurableProvider[] +@Remote listConfigurableProviders(): LlmConfigurableProvider[] /** * Offer to interrogate provider endpoints on behalf of the settings @@ -914,10 +912,10 @@ listConfigurableProviders(): LlmConfigurableProvider[] * directory, and because a provider being *added* has no route to name yet. * Disposed with the fiber. * @param settingsNs - the namespace whose profiles this discovery serves. - * @param discover - interrogates one endpoint; must honor `request.signal`. + * @param discover - interrogates one endpoint and must honor the supplied signal. * @returns the disposer that withdraws the offer. */ -registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void +registerModelDiscovery( settingsNs: string, discover: ( request: LlmModelDiscoveryRequest, signal?: AbortSignal, ) => Promise, ): () => void /** * Interrogate one provider endpoint for the models it advertises. The @@ -926,9 +924,20 @@ registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscover * candidate metadata a surface may offer for adoption. * @param settingsNs - namespace whose registered discovery serves this draft. * @param request - the endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation. * @returns the advertised models, deduplicated in endpoint order. */ -async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise +async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal, ): Promise + +/** + * Remote adapter for one draft provider interrogation. + * @param settingsNs - namespace whose registered discovery serves this draft. + * @param request - endpoint, protocol, and one-shot credential to use. + * @param signal - caller cancellation supplied by the Remote carrier. + * @returns advertised models in endpoint order. + * @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails. + */ +@Remote('discoverModels') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise /** * Resolve the retry policy captured when one provider route was registered. diff --git a/docs/subsystems/session-reference.i18n.yaml b/docs/subsystems/session-reference.i18n.yaml index 9f48576308..29ba36ab67 100644 --- a/docs/subsystems/session-reference.i18n.yaml +++ b/docs/subsystems/session-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md -session-reference.md: 429459f37132a379d18251af646181bc29d97d40 -session-reference.zh.md: b505498e560c2c5b8f211be7650cc39a16b578c3 +session-reference.md: 1dd5cc1ee8c594b34015f9bf2d765f68621b86a1 +session-reference.zh.md: 75b5018a6afbd1bbe21f303013e0e7fa0d1f89ab diff --git a/docs/subsystems/session-reference.md b/docs/subsystems/session-reference.md index 429459f371..1dd5cc1ee8 100644 --- a/docs/subsystems/session-reference.md +++ b/docs/subsystems/session-reference.md @@ -119,22 +119,33 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise - -/** - * Remote face of {@link list}; the decorator cannot mark the abstract - * member, so this concrete adapter carries the identical contract. - * @param agent - target agent whose session cwd bounds discovery. - * @param query - path text following `@` or `@"`. - * @param signal - caller cancellation. - * @returns deterministic path-only candidates. - */ -@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.md) Source: [`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) + + +### `ctx.sessionFileReferences` — `SessionFileReferences` + +Host Remote adapter over the composed file-reference provider. + +```ts cordis-catalog +/** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ +@Remote list( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.md) + +Source: [`packages/api/session-controller/src/file-references.ts`](../../packages/api/session-controller/src/file-references.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` diff --git a/docs/subsystems/session-reference.zh.md b/docs/subsystems/session-reference.zh.md index b505498e56..75b5018a6a 100644 --- a/docs/subsystems/session-reference.zh.md +++ b/docs/subsystems/session-reference.zh.md @@ -119,22 +119,33 @@ Host capability for cancellable file-reference discovery. * @returns deterministic path-only candidates. */ abstract list( agent: Agent, query: string, signal: AbortSignal, ): Promise - -/** - * Remote face of {@link list}; the decorator cannot mark the abstract - * member, so this concrete adapter carries the identical contract. - * @param agent - target agent whose session cwd bounds discovery. - * @param query - path text following `@` or `@"`. - * @param signal - caller cancellation. - * @returns deterministic path-only candidates. - */ -@Remote('list') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise ``` Types: [Agent](core.zh.md) Source: [`packages/context/file-reference/src/index.ts`](../../packages/context/file-reference/src/index.ts) + + +### `ctx.sessionFileReferences` — `SessionFileReferences` + +Host Remote adapter over the composed file-reference provider. + +```ts cordis-catalog +/** + * List file and directory candidates for one Agent's working directory. + * @param agent - target Agent resolved from the Session identity on the wire. + * @param query - path text following `@` or `@"`. + * @param signal - caller cancellation. + * @returns deterministic path-only candidates from the composed provider. + */ +@Remote list( agent: Agent, query: string, signal: AbortSignal, ): Promise +``` + +Types: [Agent](core.zh.md) + +Source: [`packages/api/session-controller/src/file-references.ts`](../../packages/api/session-controller/src/file-references.ts) + ### `ctx.sessionReferenceResolver` — `SessionReferenceResolver` diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 80666a3c88..423b9a6668 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 87e8b33dec8eda6f7a716d71f5c4afa69a420d35 -session.zh.md: d30b7d24412a04521300c657c1b07daa1620ca5f +session.md: fe3f71de6aeb6b5d9924fa88c94688f7eac8ff0a +session.zh.md: 844fd7a057c853fcbda6add875b997069ab024de diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 87e8b33dec..fe3f71de6a 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -577,6 +577,12 @@ What a persistence backend relies on: the durable log persists every event lossl The backends that consume this contract are on [persistence.md](persistence.md). +## Remote catalog and workspace opening + +`ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. + +`SessionOpenWorkspacePathRequest` carries a `sessionId` and an absolute or Session-workspace-relative `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. The controller inspects the Session without activating its Agent, resolves a relative path against the recorded cwd, and reports missing Sessions, cancellation, and opener failures through the Session Remote error vocabulary. + @@ -637,6 +643,21 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise +/** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ +@Remote('modelCatalog') modelCatalog(): Promise + +/** + * Open a path resolved against one Session's workspace on the Host desktop. + * @param request - Session identity and absolute or workspace-relative path. + * @param signal - caller lifetime; abort terminates inspection or the native command. + * @returns confirmation after the native opener accepts the path. + * @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. + */ +@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d30b7d2441..844fd7a057 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -581,6 +581,12 @@ interface TurnEndReasonMap { 消费此约定的后端见 [persistence.md](persistence.zh.md)。 +## Remote 目录与 workspace 打开 + +`ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。 + +`SessionOpenWorkspacePathRequest` 携带 `sessionId` 与绝对路径或相对于 Session workspace 的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。controller 在不激活 Agent 的前提下检查 Session,基于记录的 cwd 解析相对路径,并通过 Session Remote 错误词汇表报告 Session 缺失、取消与打开器失败。 + @@ -641,6 +647,21 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('selectModel') selectModel(request: SessionSelectModelRequest): Promise +/** + * Describe every currently routable model for Host-generation selectors. + * @returns provider-grouped models, the deployment default, and isolated provider failures. + */ +@Remote('modelCatalog') modelCatalog(): Promise + +/** + * Open a path resolved against one Session's workspace on the Host desktop. + * @param request - Session identity and absolute or workspace-relative path. + * @param signal - caller lifetime; abort terminates inspection or the native command. + * @returns confirmation after the native opener accepts the path. + * @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. + */ +@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 0c345cf749..5b83c19297 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/settings.md -settings.md: 59bd06a6e7a8659097c726b85db13caa46dd461f -settings.zh.md: a6bbdbce633a8b51167615f7838e55b698d48829 +settings.md: 467d0fc5fb4c97eeb6a18f36d879733e063bd36b +settings.zh.md: 42ed78f321021c5b7b55c51db6ef55b506550057 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 59bd06a6e7..467d0fc5fb 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -161,6 +161,10 @@ Every committed change — an in-process write or an externally observed provide type SettingsUpdateSource = 'update' | 'provider' ``` +## Native document operations + +`SettingsDocumentOpenValue` confirms that `settings/openSettingsDocument` prepared the provider-owned document and handed it to the native text editor. `AgentPresetDirectoryOpenValue` reports either a completed native handoff or the resolved user-preset directory when desktop opening is unavailable. Neither operation accepts a browser-selected Host path. + @@ -300,6 +304,23 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise + +/** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + */ +@Remote async openSettingsDocument(signal: AbortSignal): Promise + +/** + * Open one user-authored Agent preset directory or return its path when no native opener exists. + * @param agentPreset - preset id resolved against Host-owned roots. + * @param signal - caller lifetime; abort terminates the native command. + * @returns an opened confirmation or the resolved directory for text display. + * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + */ +@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index a6bbdbce63..42ed78f321 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -161,6 +161,10 @@ interface SettingsDescribeOptions { type SettingsUpdateSource = 'update' | 'provider' ``` +## 原生文档操作 + +`SettingsDocumentOpenValue` 确认 `settings/openSettingsDocument` 已准备好 provider 持有的文档,并将其交给原生文本编辑器。`AgentPresetDirectoryOpenValue` 报告已完成的原生交接,或在桌面打开不可用时返回解析后的用户 preset 目录。两项操作都不接受由浏览器选择的 Host 路径。 + @@ -300,6 +304,23 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write. */ @Remote async mutate( ns: string, ops: SettingsPathOpView[], expectedRevision: number | undefined, ): Promise + +/** + * Materialize the provider-owned settings document and open it in a native text editor. + * @param signal - caller lifetime; abort terminates preparation or the native command. + * @returns confirmation after the native opener accepts the document. + * @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails. + */ +@Remote async openSettingsDocument(signal: AbortSignal): Promise + +/** + * Open one user-authored Agent preset directory or return its path when no native opener exists. + * @param agentPreset - preset id resolved against Host-owned roots. + * @param signal - caller lifetime; abort terminates the native command. + * @returns an opened confirmation or the resolved directory for text display. + * @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened. + */ +@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise ``` Source: [`packages/api/settings-controller/src/index.ts`](../../packages/api/settings-controller/src/index.ts) diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index 93efc1068d..7bf2ead73e 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/skills.md -skills.md: 5abfa84356dc947f8b3359ad5f0fc5c42a99553a -skills.zh.md: 87759b5ab5bdfc5e9ab10cf9a86147c16893efd3 +skills.md: 18eb4d3289cec5c7807feab4229096b5799d4168 +skills.zh.md: aa167373cb9736cf24d7bf20dc965e68568826be diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 5abfa84356..18eb4d3289 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -234,6 +234,10 @@ Before each later model step, the consumer applies exact tool visibility and dig The model-facing `skill({ name })` tool validates the kebab-case name, finds the summary in the invocation-neutral catalog, rejects it before loading unless `isModelInvocable` permits access, then rereads the complete definition for the calling agent cwd and rechecks the policy before returning content. It reports an unresolved skill as unknown or no longer available and returns a tool result containing ``, ``, and ``. `resourceBase` resolves explicitly referenced scripts, references, and assets only as needed; the loaded result does not enumerate a skill directory. Body-only edits therefore change later tool calls without producing catalog messages or rewriting earlier tool results. +## Browser Session catalog + +`SkillListRequest` addresses one Session by `sessionId`; `SkillListValue` returns the user-invocable entries with name, description, optional usage guidance, and model-invocation availability. `SessionSkillCatalog` reads the Session cwd and recorded preset without activating an Agent. A live Agent may supply its scoped registry, while a cold Session uses the preset's standing scope. + @@ -242,6 +246,25 @@ The model-facing `skill({ name })` tool validates the kebab-case name, finds the Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionSkillCatalog` — `SessionSkillCatalog` + +Host service backing `ctx.remote.skills` without activating a cold Agent. + +```ts cordis-catalog +/** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + */ +@Remote async list(request: SkillListRequest, signal: AbortSignal): Promise +``` + +Source: [`packages/api/session-controller/src/skill-catalog.ts`](../../packages/api/session-controller/src/skill-catalog.ts) + ### `ctx.skills` — `SkillRegistry` diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index 87759b5ab5..aa167373cb 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -234,6 +234,10 @@ interface Config { 面向模型的 `skill({ name })` 工具校验 kebab-case 名称,在与调用策略无关的目录中查找摘要,并在加载前通过 `isModelInvocable` 拒绝无权访问的 skill;随后它根据调用方 agent 的 cwd 重新读取完整定义,并在返回内容前再次检查策略。该工具将无法解析的 skill 报告为未知或已不可用,并返回包含 ``、`` 和 `` 的工具结果。`resourceBase` 仅按需解析显式引用的脚本、参考资料和资产;加载结果不枚举 skill 目录。因此,仅修改正文会改变后续工具调用,而不会生成目录消息或改写先前工具结果。 +## 浏览器 Session 目录 + +`SkillListRequest` 通过 `sessionId` 指定一个 Session;`SkillListValue` 返回允许用户调用的条目,其中包含名称、描述、可选使用提示与模型调用可用性。`SessionSkillCatalog` 在不激活 Agent 的前提下读取 Session cwd 与记录的 preset。live Agent 可以提供其作用域 registry,冷 Session 则使用 preset 的 standing scope。 + @@ -242,6 +246,25 @@ interface Config { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.sessionSkillCatalog` — `SessionSkillCatalog` + +Host service backing `ctx.remote.skills` without activating a cold Agent. + +```ts cordis-catalog +/** + * List the user-invocable skills visible to one Session composition. + * @param request - Session identity whose cwd and preset select the catalog view. + * @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics. + * @returns user-invocable skill metadata without loading skill bodies. + * @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it. + */ +@Remote async list(request: SkillListRequest, signal: AbortSignal): Promise +``` + +Source: [`packages/api/session-controller/src/skill-catalog.ts`](../../packages/api/session-controller/src/skill-catalog.ts) + ### `ctx.skills` — `SkillRegistry` diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 4f66278d0d..41a4c618d3 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0 -README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4 +README.md: c25a7b53908f140ce866c984938401116f8e0c54 +README.zh.md: 4cc8d550e1685d8f15c71279135ede5826011f5f diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index dc04e595c9..c25a7b5390 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state. Use it through API Gateway when a Client needs these Session operations. +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and the adapter for Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. @@ -59,6 +59,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. +- File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index b34c8ad018..4cc8d550e1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。当 Client 需要这些 Session 操作时,请通过 API Gateway 使用它。 +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill 发现,以及面向 Agent 的文件引用 adapter。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 @@ -59,6 +59,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 +- 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index a0eae81529..f1369e4c8c 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -3,7 +3,7 @@ // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { IApiClient, MessageId, - RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry, + RpcError, RpcResponse, SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -156,6 +156,8 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onOpenWorkspacePath: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ opened: true as const })) onDescribe: (payload: unknown) => Promise Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onOpenPath: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ opened: true as const })) - private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -196,7 +195,6 @@ export class FakeApiClient implements IApiClient { readonly host: IApiClient['host'] = { describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), } onWorkspaceCreate: (payload: unknown) => Promise> = @@ -217,37 +215,6 @@ export class FakeApiClient implements IApiClient { onWorkspaceArchiveSession: (payload: unknown) => Promise> = payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) - // Payloads stay `unknown` (lint-lane note above); response rows are the real - // wire shapes so cases can program requires-bearing catalogs and dual-address - // skill lists without casts. - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) - - - readonly agentPresets: IApiClient['agentPresets'] = { - openDocument: (payload: { agentPreset: string }) => - this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly skills: IApiClient['skills'] = { - list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - } - - readonly settings: IApiClient['settings'] = { - openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly llm: IApiClient['llm'] = { - providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ - default: { provider: 'fixture', model: 'fixture' }, - routableProviders: [], - groups: [], - failures: [], - }))), - discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), - } - /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ sessionRemotes(): RuntimeRemotes { return { @@ -259,6 +226,15 @@ export class FakeApiClient implements IApiClient { }, session: { list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + modelCatalog: () => Promise.resolve({ + ok: true, + value: { + default: { provider: 'fixture', model: 'fixture' }, + routableProviders: [], + groups: [], + failures: [], + }, + }), search: (payload, signal) => { this.lastSearchSignal = signal return this.remoteResult('session.search', payload, this.onSearch(payload)) @@ -275,6 +251,11 @@ export class FakeApiClient implements IApiClient { attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + openWorkspacePath: payload => this.record( + 'session.openWorkspacePath', + payload, + this.onOpenWorkspacePath(payload), + ), page: request => this.page(request), follow: (request, signal) => this.openFollow(request, signal), control: signal => this.openControl(signal), diff --git a/packages/api/session-controller/tests/file-references.host.spec.ts b/packages/api/session-controller/tests/file-references.host.spec.ts new file mode 100644 index 0000000000..89e6e9f667 --- /dev/null +++ b/packages/api/session-controller/tests/file-references.host.spec.ts @@ -0,0 +1,20 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +import { describe, expect, it, vi } from 'vitest' +import { SessionFileReferences } from '../src/file-references.ts' + +describe('SessionFileReferences', () => { + it('delegates the resolved Agent, query, and cancellation signal unchanged', async () => { + const ctx = new Context() + const candidates: FileReferenceCandidate[] = [{ path: 'src', kind: 'directory' }] + const list = vi.fn(() => Promise.resolve(candidates)) + ctx.provide('fileReferences', { list } as never) + const adapter = new SessionFileReferences(ctx) + const agent = { id: 'target' } as unknown as Agent + const signal = new AbortController().signal + + await expect(adapter.list(agent, 'sr', signal)).resolves.toBe(candidates) + expect(list).toHaveBeenCalledWith(agent, 'sr', signal) + }) +}) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts new file mode 100644 index 0000000000..f8c89efd5e --- /dev/null +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -0,0 +1,95 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it, vi } from 'vitest' +import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' + +async function context(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +describe('session/openWorkspacePath', () => { + it('resolves a relative path against the attached Session cwd', async () => { + const ctx = await context() + const sessionId = SessionId('open-relative') + ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } }) + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + const signal = new AbortController().signal + + await expect(remote.openWorkspacePath({ sessionId, path: 'src/a.ts' }, signal)) + .resolves.toEqual({ ok: true, value: { opened: true } }) + expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal) + expect(ctx.agents.list()).toEqual([]) + }) + + it('preserves absolute paths and cwd-less Session paths', async () => { + const ctx = await context() + const withCwd = SessionId('open-absolute') + const withoutCwd = SessionId('open-without-cwd') + ctx.sessions.create(withCwd, { meta: { cwd: '/workspace/project' } }) + ctx.sessions.create(withoutCwd) + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await remote.openWorkspacePath({ sessionId: withCwd, path: '/tmp/result.html' }) + await remote.openWorkspacePath({ sessionId: withoutCwd, path: 'result.html' }) + expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html']) + }) + + it('rejects empty paths and missing Sessions before opening anything', async () => { + const ctx = await context() + const sessionId = SessionId('open-validation') + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([]), + inspect: () => Promise.resolve(undefined), + }) as never) + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await expect(remote.openWorkspacePath({ sessionId, path: '' })) + .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' })) + .resolves.toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + expect(openPath).not.toHaveBeenCalled() + }) + + it('preserves native opener failure and cancellation results', async () => { + const ctx = await context() + const sessionId = SessionId('open-failure') + ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } }) + const openPath = vi.fn((_path: string, _signal: AbortSignal) => + Promise.reject(new Error('desktop unavailable'))) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' })) + .resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'path open failed: desktop unavailable' }, + }) + + const aborted = new AbortController() + aborted.abort(new Error('cancelled')) + await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' }, aborted.signal)) + .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) + }) +}) diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts new file mode 100644 index 0000000000..e0640ddfe6 --- /dev/null +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -0,0 +1,191 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type {} from '@deepseek-ai/dsh-skill' +import { describe, expect, it, vi } from 'vitest' +import { SessionSkillCatalog } from '../src/skill-catalog.ts' + +function observation( + sessionId: SessionId, + options: { readonly cwd?: string; readonly agentPreset?: string } = {}, +): SessionObservation { + const events = Object.freeze([]) + const lease = (): SessionObservation => ({ + source: 'live', + header: { + version: 0, + id: sessionId, + createdAt: 1, + ...options.cwd === undefined ? {} : { cwd: options.cwd }, + }, + events, + cursor: -1, + projections: { + asOfSeq: -1, + values: { + ...options.agentPreset === undefined ? {} : { agentPreset: options.agentPreset }, + }, + }, + retain: lease, + [Symbol.dispose]: () => {}, + }) + return lease() +} + +async function context(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +describe('SessionSkillCatalog', () => { + it('reads a cold Session catalog without resuming an Agent', async () => { + const ctx = await context() + const sessionId = SessionId('cold-skills') + const observed = observation(sessionId, { cwd: '/cold/project' }) + const dispose = vi.spyOn(observed, Symbol.dispose) + const observeSession = vi.fn(() => Promise.resolve(observed)) + ctx.provide('sessionQuery', { observeSession } as never) + const resume = vi.spyOn(ctx.agents, 'resume') + const list = vi.fn(() => Promise.resolve([ + { + name: 'review', + description: 'Review the current change.', + whenToUse: 'Before publishing.', + invocation: { modelInvocable: true, userInvocable: true }, + }, + { + name: 'model-only', + description: 'Not shown to the user.', + invocation: { modelInvocable: true, userInvocable: false }, + }, + ])) + ctx.provide('skills', { list } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ + skills: [{ + name: 'review', + description: 'Review the current change.', + whenToUse: 'Before publishing.', + modelInvocable: true, + }], + }) + expect(observeSession).toHaveBeenCalledWith(sessionId) + expect(dispose).toHaveBeenCalledOnce() + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.list()).toEqual([]) + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) + }) + + it('uses a live Agent to address a preset-owned registry', async () => { + const ctx = await context() + const sessionId = SessionId('live-skills') + const session = ctx.sessions.create(sessionId, { meta: { cwd: '/live/project' } }) + const agent = { id: sessionId, session, status: 'idle', ctx } as Agent + ctx.agents.register(agent) + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/live/project' })), + } as never) + const scopedList = vi.fn(() => Promise.resolve([{ + name: 'preset-owned', + description: 'Composed for this Agent.', + invocation: { modelInvocable: false, userInvocable: true }, + }])) + const standingKeyFor = vi.fn() + ctx.provide('agentPresets', { + serviceFor: () => ({ list: scopedList }), + standingKeyFor, + } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ + skills: [{ + name: 'preset-owned', + description: 'Composed for this Agent.', + modelInvocable: false, + }], + }) + expect(scopedList).toHaveBeenCalledWith({ cwd: '/live/project', scope: agent }) + expect(standingKeyFor).not.toHaveBeenCalled() + }) + + it('uses the recorded preset standing scope for a cold Session', async () => { + const ctx = await context() + const sessionId = SessionId('standing-skills') + const scope = { agentPreset: 'minimal' } + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { + cwd: '/cold/project', + agentPreset: 'minimal', + })), + } as never) + const standingKeyFor = vi.fn(() => Promise.resolve(scope)) + ctx.provide('agentPresets', { standingKeyFor } as never) + const list = vi.fn(() => Promise.resolve([])) + ctx.provide('skills', { list } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) + expect(standingKeyFor).toHaveBeenCalledWith('minimal') + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope }) + expect(ctx.agents.list()).toEqual([]) + }) + + it('falls back to the global registry when the recorded preset is unavailable', async () => { + const ctx = await context() + const sessionId = SessionId('gone-preset') + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { + cwd: '/cold/project', + agentPreset: 'gone', + })), + } as never) + ctx.provide('agentPresets', { + standingKeyFor: () => Promise.reject(new Error('unknown preset')), + } as never) + const list = vi.fn(() => Promise.resolve([])) + ctx.provide('skills', { list } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)).resolves.toEqual({ skills: [] }) + expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) + }) + + it.each([ + { + error: new SessionQueryError( + 'session "missing-skills" not found', + 'SESSION_QUERY_SESSION_NOT_FOUND', + ), + code: 'session-not-found', + }, + { error: new Error('storage offline'), code: 'internal' }, + ] as const)('classifies failed Session inspection as $code', async ({ error, code }) => { + const ctx = await context() + ctx.provide('sessionQuery', { observeSession: () => Promise.reject(error) } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list( + { sessionId: SessionId('missing-skills') }, + new AbortController().signal, + )).rejects.toMatchObject({ failure: { code } }) + }) + + it('reports an absent skill registry instead of an empty catalog', async () => { + const ctx = await context() + const sessionId = SessionId('no-skills') + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })), + } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: expect.stringContaining('skill registry is absent') }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index ac131ab628..f5766382cb 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -32,6 +32,8 @@ import type { SessionFollowRequest, SessionListRequest, SessionListValue, + SessionOpenWorkspacePathRequest, + SessionOpenWorkspacePathValue, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -58,6 +60,10 @@ export interface TestSessionRemote { attachment(request: SessionAttachmentRequest): Promise> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> + openWorkspacePath( + request: SessionOpenWorkspacePathRequest, + signal?: AbortSignal, + ): Promise> page(request: SessionPageRequest, signal?: AbortSignal): Promise> follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable control(signal?: AbortSignal): AsyncIterable @@ -69,6 +75,7 @@ export interface TestSessionRemoteDefaults { readonly cwd: string readonly coldBlankProbeMaxBytes?: number readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise + readonly openPath?: (path: string, signal: AbortSignal) => Promise } const installed = new WeakMap() @@ -174,9 +181,13 @@ function installControllers( const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) let controller: SessionController try { - controller = new SessionController(ctx, defaults.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }) + controller = new SessionController( + ctx, + defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, + defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ) } finally { cwd.mockRestore() } @@ -239,6 +250,10 @@ export function createSessionTestRemote( attachment: request => remoteResult(() => direct.attachment(request)), updateQueue: request => remoteResult(() => direct.updateQueue(request)), cancel: request => remoteResult(() => direct.cancel(request)), + openWorkspacePath: (request, signal = new AbortController().signal) => remoteResult( + () => direct.openWorkspacePath(request, signal), + signal, + ), page: (request, signal = new AbortController().signal) => remoteResult( () => direct.page(request, signal), signal, diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index f393e41d2e..3b05b15761 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/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/api/settings-controller/README.md -README.md: f57bab1cf68ff05d807102a831f4ad9ce65dba76 -README.zh.md: 41062db004b8c98f544f70c42137a71aee997ec8 +README.md: b545b27ff1716e54be1a25a9bbd4131f86a12b68 +README.zh.md: 46977c6e0e3fbd43eb0a688b37e7db6eb7007f85 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index f57bab1cf6..b545b27ff1 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -1,5 +1,5 @@ --- -description: "Host Remote owner for settings and credential configuration surfaces, including redacted reads, path-addressed settings writes, and credential reference management." +description: "Host Remote owner for settings and credential configuration surfaces, including redacted reads, writes, credential references, and native document opening." kind: "package-reference" --- # Settings Controller @@ -8,11 +8,12 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports merge, replacement, and path-addressed settings writes, and stores or removes credential references without returning secret values. When either provider is absent, its namespace remains registered and returns an actionable configuration error. +`@deepseek-ai/dsh-api-settings-controller` exposes generated `ctx.remote.settings` and `ctx.remote.credentials` namespaces for browser configuration surfaces. It returns redacted settings and credential metadata, supports settings and credential writes without returning secret values, and opens provider-owned settings or Agent preset locations on the Host desktop. When a provider is absent, the namespace remains registered and returns an actionable configuration error. ## Table of Contents - [Use this package](#use-this-package) +- [Configuration](#configuration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -28,6 +29,19 @@ Mount this package as a Loader entry in a profile that serves browser configurat `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. +`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither method accepts a browser-supplied filesystem target. + +----- + + +## Configuration + +| Field | Default | Meaning | +|---|---|---| +| `nativeOpen` | platform-detected | Whether Agent preset directories can be handed to a native desktop opener | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-settings-controller) is the exhaustive source for accepted fields and their JSDoc. + ----- @@ -43,7 +57,6 @@ No direct effect; reading or writing these configuration values does not alter m -- Settings document opening uses API Proxy rather than this Remote namespace. - The batch bound is fixed at 64 references and is not a deployment-configurable field. diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 41062db004..46977c6e0e 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -1,5 +1,5 @@ --- -description: "settings 与凭据配置界面的 Host Remote owner,涵盖脱敏读取、按路径写入 settings 和管理凭据引用。" +description: "settings 与凭据配置界面的 Host Remote owner,涵盖脱敏读取、写入、凭据引用与原生文档打开。" kind: "package-reference" --- # Settings Controller @@ -8,11 +8,12 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持合并、替换和按路径表达的 settings 写入,并在不返回密钥值的前提下写入或移除凭据引用。任一 provider 缺失时,对应 namespace 仍会注册,并返回可操作的配置错误。 +`@deepseek-ai/dsh-api-settings-controller` 为浏览器配置界面提供生成的 `ctx.remote.settings` 与 `ctx.remote.credentials` namespace。它返回脱敏的 settings 与凭据元数据,支持 settings 与凭据写入而不返回密钥值,并在 Host 桌面打开由 provider 持有的 settings 或 Agent preset 位置。provider 缺失时,namespace 仍会注册,并返回可操作的配置错误。 ## 目录 - [使用本包](#use-this-package) +- [配置](#configuration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -28,6 +29,19 @@ kind: "package-reference" `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 +`settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两种方法都不接受浏览器提供的文件系统目标。 + +----- + + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `nativeOpen` | 平台探测 | Agent preset 目录能否交给原生桌面打开器 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-settings-controller)是所有受支持字段及其 JSDoc 的完整来源。 + ----- @@ -43,7 +57,6 @@ kind: "package-reference" -- settings 文档打开使用 API Proxy,而不经过本 Remote namespace。 - 批量上限固定为 64 个引用,不是可按部署配置的字段。 diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index 894fbddd53..4b3b973eae 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' @@ -80,6 +80,8 @@ describe('the settings Remote namespace a configuration page calls', () => { { method: 'update', invocation: { kind: 'direct' } }, { method: 'replace', invocation: { kind: 'direct' } }, { method: 'mutate', invocation: { kind: 'direct' } }, + { method: 'openSettingsDocument', invocation: { kind: 'direct' } }, + { method: 'openAgentPresetDirectory', invocation: { kind: 'direct' } }, ]) }) @@ -91,6 +93,7 @@ describe('the settings Remote namespace a configuration page calls', () => { () => ctx.settingsController.update('ui-test', {}, undefined), () => ctx.settingsController.replace('ui-test', {}, undefined), () => ctx.settingsController.mutate('ui-test', [], undefined), + () => ctx.settingsController.openSettingsDocument(new AbortController().signal), ] for (const call of calls) { const failure = await Promise.resolve().then(call).catch((error: unknown) => error) @@ -239,4 +242,108 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(code).toBe('internal') expect(message).toContain('was disposed after the mutate') }) + + it('prepares and opens the provider-owned settings document', async () => { + const ctx = new Context() + await ctx.plugin(DocumentSettings) + const prepare = vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') + const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const controller = new SettingsController(ctx, {}, { openTextFile }) + const signal = new AbortController().signal + + await expect(controller.openSettingsDocument(signal)).resolves.toEqual({ opened: true }) + expect(prepare).toHaveBeenCalledOnce() + expect(openTextFile).toHaveBeenCalledWith('/tmp/settings.yaml', signal) + }) + + it('preserves settings-document absence, failure, and cancellation', async () => { + const absent = await boot() + await expect(absent.controller.openSettingsDocument(new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'internal', message: expect.stringContaining('no local document') } }) + + const failed = await boot(DocumentSettings) + vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed')) + await expect(failed.controller.openSettingsDocument(new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'internal', message: expect.stringContaining('read failed') } }) + + const cancelled = new AbortController() + cancelled.abort(new Error('cancelled')) + const prepare = vi.spyOn(failed.ctx.settings, 'prepareDocument') + prepare.mockClear() + await expect(failed.controller.openSettingsDocument(cancelled.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + expect(prepare).not.toHaveBeenCalled() + }) + + it('does not open a settings document cancelled during preparation', async () => { + const ctx = new Context() + await ctx.plugin(DocumentSettings) + const prepared = Promise.withResolvers() + vi.spyOn(ctx.settings, 'prepareDocument').mockReturnValue(prepared.promise) + const openTextFile = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const controller = new SettingsController(ctx, {}, { openTextFile }) + const abort = new AbortController() + + const opening = controller.openSettingsDocument(abort.signal) + abort.abort(new Error('cancelled')) + prepared.resolve('/tmp/settings.yaml') + + await expect(opening).rejects.toMatchObject({ failure: { code: 'cancelled' } }) + expect(openTextFile).not.toHaveBeenCalled() + }) + + it('maps native settings-document opener failures', async () => { + const ctx = new Context() + await ctx.plugin(DocumentSettings) + vi.spyOn(ctx.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') + const controller = new SettingsController(ctx, {}, { + openTextFile: () => Promise.reject(new Error('no default editor')), + }) + + await expect(controller.openSettingsDocument(new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: 'path open failed: no default editor' }, + }) + }) + + it('opens a user Agent preset directory or returns its path without a native opener', async () => { + const ctx = new Context() + ctx.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) + const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + const signal = new AbortController().signal + await expect(openable.openAgentPresetDirectory('mine', signal)) + .resolves.toEqual({ opened: true }) + expect(openPath).toHaveBeenCalledWith('/presets/mine', signal) + + const headless = new Context() + headless.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const reveal = new SettingsController(headless, { nativeOpen: false }) + await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal)) + .resolves.toEqual({ opened: false, path: '/presets/mine' }) + }) + + it('refuses a shipped Agent preset and a missing preset provider', async () => { + const ctx = new Context() + ctx.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'system', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const controller = new SettingsController(ctx) + await expect(controller.openAgentPresetDirectory('standard', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'agent-preset-read-only' } }) + + const missing = new SettingsController(new Context()) + await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } }) + }) }) diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index a2cb5f78b8..bc546ad185 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -1,7 +1,7 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). The generation source is a hand pump. -import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts' +import type { IApiClient, RpcResponse } from '../src/client/api.ts' import type { ConnectionGenerationSource } from '../src/client/connection.ts' import { RpcId } from '../src/client/api.ts' @@ -50,44 +50,11 @@ export class FakeApiClient implements IApiClient { () => Promise.resolve(ok({ version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, })) - onOpenPath: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ opened: true as const })) private readonly generationConns: StreamConn[] = [] readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), - } - - // Payloads stay `unknown` (lint-lane note above); response rows are the real - // wire shapes so cases can program catalogs and skill lists without casts. - onSkillList: (payload: unknown) => Promise> - = () => Promise.resolve(ok({ skills: [] })) - - - readonly agentPresets: IApiClient['agentPresets'] = { - openDocument: (payload: { agentPreset: string }) => - this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly skills: IApiClient['skills'] = { - list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), - } - - readonly settings: IApiClient['settings'] = { - openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - } - - readonly llm: IApiClient['llm'] = { - providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ - default: { provider: 'fixture', model: 'fixture' }, - routableProviders: [], - groups: [], - failures: [], - }))), - discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } /** When true, the source never reports ready. */ diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 163fb414a6..818e34d92a 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -1,14 +1,10 @@ /** * Fixture commands/skills domains: session-addressed catalogs, execute - * parse/dispatch and its logged lifecycle pair, skill.list session resolution, - * and the FixtureApiClient dispatch rows. Commands answer on the Remote face - * and skills on the legacy API face, so both are driven here. + * parse/dispatch and its logged lifecycle pair, and skills/list Session resolution. */ import { describe, expect, it } from 'vitest' import type { SessionId } from '../src/client/api.ts' -import { RpcId } from '../src/client/api.ts' -import type { RpcRequest } from '../src/client/api.ts' -import { FixtureApiClient, createFixtureApi, createFixtureFaces } from '../src/client/fixture.ts' +import { createFixtureFaces } from '../src/client/fixture.ts' /** Drive one commands Remote endpoint against the fixture state graph. */ async function callRemote( @@ -22,8 +18,6 @@ async function callRemote( } const sid = (id: string): SessionId => id as SessionId -let reqCount = 0 -const req =

    (payload: P): RpcRequest

    => ({ rpcId: RpcId(`t-${reqCount++}`), payload }) describe('createFixtureApi commands/skills', () => { it('serves the addressed session catalog', async () => { @@ -162,26 +156,30 @@ describe('createFixtureApi commands/skills', () => { }) it('serves the skill catalog for the addressed session and rejects unknown sessions', async () => { - const api = createFixtureApi() - const response = await api.skills.list(req({ sessionId: sid('fx-alpha') })) - if (!response.result.ok) throw new Error('skill list failed') - expect(response.result.value.skills[0]?.name).toBe('fixture-demo') + const { rpc } = createFixtureFaces() + const skills = await callRemote<{ skills: Array<{ name: string }> }>( + rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } }, + ) + expect(skills.skills[0]?.name).toBe('fixture-demo') - const missingSession = await api.skills.list(req({ sessionId: sid('fx-nope') })) - expect(missingSession.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) + const missingSession = await rpc.call('/api', 'skills/list', { + args: { request: { sessionId: sid('fx-nope') } }, + }) + expect(missingSession).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) }) describe('FixtureApiClient command/skill dispatch', () => { - it('routes the Remote commands face and the legacy skill row through one state graph', async () => { - const client = new FixtureApiClient() - const commands = await callRemote<{ name: string }[]>(client.rpc, 'commands/list', { agentId: sid('fx-alpha') }) + it('routes the Remote command and skill rows through one state graph', async () => { + const { rpc } = createFixtureFaces() + const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') }) expect(commands.length).toBeGreaterThan(0) const executed = await callRemote<{ commandId: string } | undefined>( - client.rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) + rpc, 'commands/execute', { agentId: sid('fx-alpha'), line: '/compact' }) expect(executed?.commandId).toBeTruthy() - const skills = await client.skills.list({ sessionId: sid('fx-alpha') }) - if (!skills.result.ok) throw new Error('skill.list failed') - expect(skills.result.value.skills.length).toBeGreaterThan(0) + const skills = await callRemote<{ skills: unknown[] }>( + rpc, 'skills/list', { request: { sessionId: sid('fx-alpha') } }, + ) + expect(skills.skills.length).toBeGreaterThan(0) }) }) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index de8cbf42d9..8e41867de0 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -20,6 +20,7 @@ import type { ClientConnectionRpc, ConnectionRpcResult, } from '../src/rpc.ts' import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' +import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -175,6 +176,7 @@ type FixtureSessionClient = { } interface FixtureSessionRemote { + modelCatalog(): Promise> follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable control(signal: AbortSignal): AsyncIterable } @@ -446,6 +448,8 @@ function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { return stream as AsyncIterable } return { + modelCatalog: () => rpc.call('/api', 'session/modelCatalog', { args: {} }) as + Promise>, follow: (sessionId, signal) => open('session/follow', { request: { address: { kind: 'session', sessionId } }, }, signal), @@ -732,10 +736,10 @@ describe('createFixtureApi', () => { it('serves grouped models and keeps a selection for later history and fixture requests', async () => { const api = createFixtureApi() const sessionId = sid('fx-alpha') - const catalog = await api.llm.models(req({})) - if (!catalog.result.ok) throw new Error('models failed') - expect(catalog.result.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) - expect(catalog.result.value.groups[0]?.models.map(model => model.id)) + const catalog = await api.sessionRemote.modelCatalog() + if (!catalog.ok) throw new Error('models failed') + expect(catalog.value.groups.map(group => group.name)).toEqual(['DeepSeek', 'OpenAI']) + expect(catalog.value.groups[0]?.models.map(model => model.id)) .toEqual(['deepseek-v4-flash', 'deepseek-v4-pro']) const selected = await api.sessions.selectModel(req({ diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 4a5121b97b..4f4fc17e39 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -174,8 +174,8 @@ describe('connection node half', () => { it('requires the same browser session for every method on every trusted authority', async () => { const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const methods = [ - 'host.openPath', - 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', + 'session/openWorkspacePath', + 'llm/discoverModels', 'skills/list', 'settings/openAgentPresetDirectory', ] for (const method of methods) { const denied = fakeResponse() @@ -500,17 +500,17 @@ describe('connection node half over a real HTTP server', () => { const { port, close } = await serve(routes) try { const methods = [ - 'settings.openDocument', - 'host.openPath', - 'llm.discoverModels', - 'agentPreset.openDocument', - 'llm.providers', 'llm.models', + 'settings/openSettingsDocument', + 'session/openWorkspacePath', + 'llm/discoverModels', 'skills/list', + 'settings/openAgentPresetDirectory', + 'llm/listProviders', 'session/modelCatalog', ] for (const method of methods) { expect([method, await call(port, method, 'localhost')]).toEqual([method, 401]) expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401]) } - expect(await call(port, 'settings.openDocument', 'other.example')).toBe(403) + expect(await call(port, 'settings/openSettingsDocument', 'other.example')).toBe(403) const declaredCookie = browserCookie(connection, 'harness.example') for (const method of methods) { @@ -519,7 +519,7 @@ describe('connection node half over a real HTTP server', () => { const loopbackAuthority = `127.0.0.1:${String(port)}` expect(await call( port, - 'settings.openDocument', + 'settings/openSettingsDocument', loopbackAuthority, browserCookie(connection, loopbackAuthority), )).toBe(404) diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 3ff87b2d8f..f3461bcbf7 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 1f07b034839dafcb0794d1a2e41b5015f5a55df0 -README.zh.md: 2aa5420f2092d974e293ddce97d34517c63e37b8 +README.md: 495d38631257bef3f9dbf74b7762c357b90779e4 +README.zh.md: fdf0a994b00c8a28e2bd0dc4dfa7238d25c4774b diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 1f07b03483..495d386312 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card

    Implementation internals — click to expand -Options and the current default both come from one `agentPreset.list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPreset.read`, `copy`, `openDocument`, `remove`, `list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, remove, and openDocument manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/changed`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
    diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index 2aa5420f20..fdf0a994b0 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference"
    实现细节——点击展开 -选项与当前默认值都来自同一次 `agentPreset.list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPreset.read`、`copy`、`openDocument`、`remove`、`list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、remove 与 openDocument 管理名单并驱动宿主桌面。分区在自身操作、`settings/changed` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。 +选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
    diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 2cde10fc4e..b7e73785e4 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -82,6 +82,10 @@ async function bench() { calls.push(`settings:${JSON.stringify(patch)}`) return Promise.resolve({ ok: true as const, value: {} }) }, + openAgentPresetDirectory: (agentPreset: string) => { + calls.push(`openAgentPresetDirectory:${agentPreset}`) + return Promise.resolve({ ok: true as const, value: { opened: true as const } }) + }, } const remote = new TestRemote(ctx, { settings }) // The roster and the switch are the AgentPresets Remote namespace; the @@ -118,12 +122,6 @@ async function bench() { result: { ok: true as const, value: { canOpenPath: true } }, }), }, - agentPresets: { - openDocument: (payload: { agentPreset: string }) => { - calls.push(`openDocument:${payload.agentPreset}`) - return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { opened: true as const } } }) - }, - }, }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() @@ -257,7 +255,7 @@ describe('ui-agent-preset apply', () => { // one the roster re-read reflects, and the delete the section confirmed // is the one its remove() sees. expect(calls).toContain('copy:mine') - expect(calls.filter(call => call === 'openDocument:mine').length).toBeGreaterThan(0) + expect(calls.filter(call => call === 'openAgentPresetDirectory:mine').length).toBeGreaterThan(0) expect(section.hooks.agentPresetSection.getSnapshot().rows).toHaveLength(2) }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index 8d925f4978..df1ad1e201 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -8,7 +8,6 @@ import { describe, expect, it } from 'vitest' import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' -import type { SettingsWireFace } from '@deepseek-ai/dsh-client-ui-settings/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -49,9 +48,6 @@ interface FakeOptions { } const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) -const fail = (message: string) => - Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } }) - const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) const remoteFail = (message: string) => Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) @@ -64,36 +60,15 @@ const remoteFail = (message: string) => * @returns the fake client. */ function fakeApi( - defaultId: { id: string }, options: FakeOptions = {}, -): SettingsWireFace & Pick { - const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } +): Pick { return { host: { describe: () => (options.throwDescribe === true ? Promise.reject(new Error('socket closed')) : ok({ canOpenPath: options.hasDocument ?? true })), }, - agentPresets: { - openDocument: (payload: { agentPreset: string }) => { - record('openDocument', payload) - if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) - if (options.failOpen !== undefined) return fail(options.failOpen) - return (options.hasDocument ?? true) - ? ok({ opened: true }) - : ok({ opened: false, path: `/presets/${payload.agentPreset}` }) - }, - }, - settings: { - update: (ns: string, patch: { default?: string }) => { - record('settings.update', { ns, patch }) - if (options.failSettings !== undefined) return remoteFail(options.failSettings) - /* v8 ignore next -- the controller only ever sets `default` */ - defaultId.id = patch.default ?? defaultId.id - return remoteOk({}) - }, - }, - } as unknown as SettingsWireFace & Pick + } as Pick } /** @@ -108,7 +83,7 @@ function fakeRemote( presets: Map, defaultId: { id: string }, options: FakeOptions = {}, -): Pick { +): Pick { const record = (method: string, payload: unknown): void => { options.calls?.push({ method, payload }) } return { agentPresets: { @@ -167,7 +142,24 @@ function fakeRemote( return await remoteOk(undefined) }, }, - } as unknown as Pick + settings: { + update: (ns: string, patch: { default?: string }) => { + record('settings.update', { ns, patch }) + if (options.failSettings !== undefined) return remoteFail(options.failSettings) + /* v8 ignore next -- the controller only ever sets `default` */ + defaultId.id = patch.default ?? defaultId.id + return remoteOk({}) + }, + openAgentPresetDirectory: (agentPreset: string) => { + record('openAgentPresetDirectory', { agentPreset }) + if (options.throwOpen === true) return Promise.reject(new Error('socket closed')) + if (options.failOpen !== undefined) return remoteFail(options.failOpen) + return (options.hasDocument ?? true) + ? remoteOk({ opened: true }) + : remoteOk({ opened: false, path: `/presets/${agentPreset}` }) + }, + }, + } as unknown as Pick } function seed(): Map { @@ -184,7 +176,7 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeApi(defaultId, wired), + fakeApi(wired), fakeRemote(presets, defaultId, wired), () => { rosterChanges += 1 }, ) @@ -406,7 +398,7 @@ describe('submitting a copy', () => { .toEqual({ from: 'standard', id: 'my-copy', name: '我的模式' }) // A preset is its files from here on, so landing in them completes the // copy rather than following it. - expect(calls.find(call => call.method === 'openDocument')?.payload) + expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload) .toEqual({ agentPreset: 'my-copy' }) }) @@ -476,7 +468,7 @@ describe('the location action', () => { await controller.openLocation('mine') - expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' }) + expect(calls.find(call => call.method === 'openAgentPresetDirectory')?.payload).toEqual({ agentPreset: 'mine' }) expect(controller.store.getSnapshot().revealedPaths).toEqual({}) }) @@ -580,13 +572,14 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { agentPresets: {}, settings: {}, host: {} } as unknown as SettingsWireFace & Pick, + { host: {} } as unknown as Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), deletePreset: () => Promise.reject(new Error('socket closed')), }, - } as unknown as Pick, + settings: {}, + } as unknown as Pick, ) broken.confirmDelete('mine') @@ -603,7 +596,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeApi(defaultId), fakeRemote(presets, defaultId)) + fakeApi(), fakeRemote(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 328868a2dc..2a0d9b045c 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -5,9 +5,10 @@ import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { - SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, + SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runtime' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { apply as applyConversation, inject as injectConversation, } from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -48,10 +49,12 @@ async function bench() { runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.ctx.provide('layout', layout as never) - const openPath = vi.fn<(path: string) => Promise>(async () => {}) + const openWorkspacePath = vi.fn( + () => Promise.resolve({ ok: true, value: { opened: true } }), + ) + new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => ROOT), - openPath, } as never) const session = sessionFakeFor() await runtime.sessions.add({ @@ -79,7 +82,7 @@ async function bench() { ) => ChatViewInjected)(id, instance.actions) return { instance, injected } } - return { runtime, layout, openPath, session, chatViewApi } + return { runtime, layout, openWorkspacePath, session, chatViewApi } } describe('Chat inject API', () => { @@ -120,10 +123,13 @@ describe('Chat inject API', () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) await injected.openFile('src/a.ts') - expect(b.openPath).toHaveBeenCalledWith('/proj/src/a.ts') + expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: ROOT, path: 'src/a.ts' }) - b.openPath.mockRejectedValueOnce(new Error('xdg-open is not available')) - await expect(injected.openFile('src/b.ts')).rejects.toThrow('xdg-open is not available') + b.openWorkspacePath.mockResolvedValueOnce({ + ok: false, + error: { code: 'internal', message: 'xdg-open is not available', details: {} }, + }) + await expect(injected.openFile('src/b.ts')).rejects.toThrow('path open failed: xdg-open is not available') await b.runtime.dispose() }) diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index 537bea3e56..d9c1538190 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { describe, expect, it, vi } from 'vitest' import { - chatSnapshot, SlotTestRuntime, stubSettingsScope, usePinnedBrowserLanguages, + chatSnapshot, SlotTestRuntime, TestRemote, stubSettingsScope, usePinnedBrowserLanguages, } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' @@ -40,8 +40,10 @@ async function bench() { runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() } as never) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), - openPath: vi.fn(async () => {}), } as never) + new TestRemote(runtime.ctx, { + session: { openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })) }, + }) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) diff --git a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts index 459c972208..1bf11733b5 100644 --- a/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-model-selection/tests/browser-plugin.client.spec.ts @@ -65,6 +65,18 @@ async function bench() { // block follows this, never catalog membership. let routable = true const sessionRemote = { + modelCatalog: () => { + calls.models += 1 + return Promise.resolve({ + ok: true as const, + value: { + default: defaultSelection, + routableProviders: routable ? ['deepseek-official'] : [], + groups: GROUPS, + failures: [], + }, + }) + }, selectModel: (payload: { sessionId: SessionId; provider: string; model: string; reasoningEffort?: string }) => { calls.select += 1 selected = { @@ -80,28 +92,6 @@ async function bench() { } const remote = Object.assign(new TestRemote(ctx), { session: sessionRemote }) ctx.reflect.provide('remote.session', sessionRemote) - ctx.provide('connection', { - api: { - llm: { - models: () => { - calls.models += 1 - return Promise.resolve({ - rpcId: 'model-catalog', - result: { - ok: true as const, - value: { - default: defaultSelection, - routableProviders: routable ? ['deepseek-official'] : [], - groups: GROUPS, - failures: [], - }, - }, - }) - }, - }, - }, - isLoopback: false, - } as never) const blocks = new Map() ctx.provide('conversation', { blocks: { diff --git a/packages/client/ui-model-selection/tests/catalog.client.spec.ts b/packages/client/ui-model-selection/tests/catalog.client.spec.ts index 95f3d440b4..a9333113e8 100644 --- a/packages/client/ui-model-selection/tests/catalog.client.spec.ts +++ b/packages/client/ui-model-selection/tests/catalog.client.spec.ts @@ -1,4 +1,4 @@ -import type { IApiClient, ModelCatalog } from '@deepseek-ai/dsh-client-connection/client' +import type { ClientRemote, ModelCatalog } from '@deepseek-ai/dsh-api-remotes/client' import { describe, expect, it, vi } from 'vitest' import { ModelCatalogDirectory } from '../src/client/catalog.ts' @@ -10,16 +10,16 @@ const catalog = (model: string): ModelCatalog => ({ }) function directory(models: () => Promise): ModelCatalogDirectory { - return new ModelCatalogDirectory({ llm: { models } } as unknown as IApiClient) + return new ModelCatalogDirectory({ modelCatalog: models } as unknown as ClientRemote['session']) } describe('ModelCatalogDirectory', () => { it('shares one failing request, exposes the RPC error, and permits a retry', async () => { const models = vi.fn() .mockResolvedValueOnce({ - result: { ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} } }, + ok: false, error: { code: 'unavailable', message: 'catalog offline', details: {} }, }) - .mockResolvedValueOnce({ result: { ok: true, value: catalog('recovered') } }) + .mockResolvedValueOnce({ ok: true, value: catalog('recovered') }) const subject = directory(models) const first = subject.load() @@ -40,10 +40,10 @@ describe('ModelCatalogDirectory', () => { const stale = subject.load() subject.resetGeneration() - first.resolve({ result: { ok: true, value: catalog('stale') } }) + first.resolve({ ok: true, value: catalog('stale') }) await expect(stale).resolves.toEqual(catalog('stale')) expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading' }) - second.resolve({ result: { ok: true, value: catalog('fresh') } }) + second.resolve({ ok: true, value: catalog('fresh') }) await vi.waitFor(() => { expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' }) }) @@ -62,7 +62,7 @@ describe('ModelCatalogDirectory', () => { first.reject(new Error('stale failure')) await expect(stale).rejects.toThrow('stale failure') expect(subject.store.getSnapshot()).toMatchObject({ value: null, status: 'loading', error: null }) - second.resolve({ result: { ok: true, value: catalog('fresh') } }) + second.resolve({ ok: true, value: catalog('fresh') }) await vi.waitFor(() => { expect(subject.store.getSnapshot()).toMatchObject({ value: catalog('fresh'), status: 'ready' }) }) @@ -70,7 +70,7 @@ describe('ModelCatalogDirectory', () => { it('contains refresh failures while retaining old data and clears it on a failed Host reset', async () => { const models = vi.fn() - .mockResolvedValueOnce({ result: { ok: true, value: catalog('old') } }) + .mockResolvedValueOnce({ ok: true, value: catalog('old') }) .mockRejectedValueOnce('refresh failed') .mockRejectedValueOnce(new Error('reset failed')) const subject = directory(models) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index 7041d38cad..93ac13b82e 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/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-settings-general/README.md -README.md: 0c42e7c8c63ee720e6766471de8e43d97624418b -README.zh.md: 09cacf4bcdd1c47b66b34840ccda2c15ce3dc2f2 +README.md: f0c4ab7c50798919d42ade0eac6a9a93c8a4df1c +README.zh.md: 912ea0fb16c0d1d512ff846c8eda1e724664f1ea diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index 0c42e7c8c6..f0c4ab7c50 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -55,7 +55,7 @@ The navigation is a projection of the `settings.section` ledger; nav labels may ### Document availability -On a loopback page, the Client loads the provider's `hasDocument` capability through `settings.describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action sends the pathless, browser-authenticated `settings.openDocument` request; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. +On a loopback page, the Client loads the provider's `hasDocument` capability through `settings/describe` and renders **Open configuration file** only when the Host confirms that a provider-owned local document can be prepared. The action calls the pathless, browser-authenticated `settings/openSettingsDocument` Remote; the Host resolves the provider path again, materializes an absent document, and hands it to a native text editor (`open -t` on macOS, bypassing a browser file association; the desktop file association on Linux and Windows; Windows association after `wslpath -w` translation on WSL). Open failures keep the action available and render a localized error. Reopening the dialog or reconnecting refreshes availability after a transient read failure or Host topology change. Non-loopback pages retain the Client policy that withholds this native action and its settings read. ### Host half diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 09cacf4bcd..912ea0fb16 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -55,7 +55,7 @@ kind: "package-reference" ### 文档可用性 -在 loopback 页面上,Client 通过 `settings.describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染配置文件操作。该操作发送无路径参数且经浏览器认证的 `settings.openDocument` 请求;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 +在 loopback 页面上,Client 通过 `settings/describe` 加载提供方的 `hasDocument` 能力,且只有在 Host 确认可准备好一份由提供方持有的本地文档时才渲染配置文件操作。该操作调用无路径参数且经浏览器认证的 `settings/openSettingsDocument` Remote;Host 会再次解析提供方路径、在文档缺失时将其创建出来,并交给原生文本编辑器(macOS 上使用 `open -t`,绕过浏览器文件关联;Linux 和 Windows 上使用桌面文件关联;WSL 上经 `wslpath -w` 转换后使用 Windows 文件关联)。打开失败时该操作仍可使用,并渲染本地化错误。临时读取失败或 Host 拓扑变化后,重新打开对话框或重新连接会刷新可用性。非 loopback 页面保留 Client 策略,不提供该原生操作及其 settings 读取。 ### 宿主端 diff --git a/packages/client/ui-settings-general/tests/apply.client.spec.ts b/packages/client/ui-settings-general/tests/apply.client.spec.ts index bf2174d571..86ec40db91 100644 --- a/packages/client/ui-settings-general/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.client.spec.ts @@ -40,14 +40,14 @@ async function bench(isLoopback = true) { }, })) const settingsOpenDocument = vi.fn(() => Promise.resolve({ - rpcId: 'settings-open' as never, - result: { ok: true as const, value: { opened: true as const } }, + ok: true as const, value: { opened: true as const }, })) ctx.provide('connection', { - api: { settings: { openDocument: settingsOpenDocument } }, isLoopback, } as never) - new TestRemote(ctx, { settings: { describe: settingsDescribe } }) + new TestRemote(ctx, { + settings: { describe: settingsDescribe, openSettingsDocument: settingsOpenDocument }, + }) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, settingsDescribe, settingsOpenDocument } } @@ -76,7 +76,7 @@ function generalEntry(slots: SlotRegistry) { describe('ui-settings-general apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope']) + expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope']) }) it('fills all five seats for declarations before or after apply', async () => { diff --git a/packages/client/ui-settings-general/tests/components.client.spec.tsx b/packages/client/ui-settings-general/tests/components.client.spec.tsx index 9c2271db4b..19464e6778 100644 --- a/packages/client/ui-settings-general/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/components.client.spec.tsx @@ -71,8 +71,7 @@ describe('GeneralSection', () => { describe('SettingsDocumentAction', () => { it('appears only for a file-backed provider and requests its Host-owned document', async () => { const openDocument = vi.fn(() => Promise.resolve({ - rpcId: 'document-open' as never, - result: { ok: true as const, value: { opened: true as const } }, + ok: true as const, value: { opened: true as const }, })) const controller = derivedDocumentStore({ settings: { @@ -80,7 +79,7 @@ describe('SettingsDocumentAction', () => { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, })), - openDocument, + openSettingsDocument: openDocument, }, }) render( { />) const action = await screen.findByRole('button', { name: 'Open configuration file' }) fireEvent.click(action) - await waitFor(() => { expect(openDocument).toHaveBeenCalledWith({}) }) + await waitFor(() => { expect(openDocument).toHaveBeenCalledWith() }) }) it('stays absent without a document and follows a mirror refresh to available', async () => { const describe = vi.fn() .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } }) .mockResolvedValueOnce({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] } }) - const wire = { settings: { describe, openDocument: vi.fn() } } as never + const wire = { settings: { describe, openSettingsDocument: vi.fn() } } as never const mirror = new SettingsDescribeMirror(wire) const controller = new SettingsDocumentStore(wire, mirror) const first = render( { ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, })), - openDocument: vi.fn(() => Promise.resolve({ - rpcId: 'document-open-failed' as never, - result: { ok: false as const, error: { code: 'internal' as const, message: 'xdg-open missing', details: {} } }, + openSettingsDocument: vi.fn(() => Promise.resolve({ + ok: false as const, + error: { code: 'internal' as const, message: 'xdg-open missing', details: {} }, })), }, }) diff --git a/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts b/packages/client/ui-settings-general/tests/settings-document-store.client.spec.ts index 85c55c6ad0..b166e8b452 100644 --- a/packages/client/ui-settings-general/tests/settings-document-store.client.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-api-remotes/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { SettingsDocumentStore } from '../src/client/settings-document-store.ts' @@ -13,11 +13,8 @@ function response(hasDocument = false) { return { ok: true, value: { writable: true, hasDocument, namespaces: [] } } } -function opened(): RpcResponse<{ opened: true }> { - return { - rpcId: 'settings-open' as never, - result: { ok: true, value: { opened: true } }, - } +function opened(): RemoteResult<{ opened: true }> { + return { ok: true, value: { opened: true } } } function describeFailed(message: string) { @@ -28,19 +25,19 @@ describe('SettingsDocumentStore', () => { it('loads provider metadata and asks the settings domain to open its document', async () => { const describe = vi.fn(() => Promise.resolve(response(true))) const openDocument = vi.fn(() => Promise.resolve(opened())) - const controller = derivedDocumentStore({ settings: { describe, openDocument } }) + const controller = derivedDocumentStore({ settings: { describe, openSettingsDocument: openDocument } }) await controller.load() expect(controller.store.getSnapshot()).toEqual({ status: 'ready', opening: false, error: null, }) await controller.open() - expect(openDocument).toHaveBeenCalledWith({}) + expect(openDocument).toHaveBeenCalledWith() }) it('marks absent or failed metadata unavailable without opening anything', async () => { const openDocument = vi.fn(() => Promise.resolve(opened())) const absent = derivedDocumentStore({ - settings: { describe: () => Promise.resolve(response()), openDocument }, + settings: { describe: () => Promise.resolve(response()), openSettingsDocument: openDocument }, }) await absent.load() await absent.open() @@ -48,13 +45,13 @@ describe('SettingsDocumentStore', () => { expect(openDocument).not.toHaveBeenCalled() const failed = derivedDocumentStore({ - settings: { describe: () => Promise.reject(new Error('offline')), openDocument }, + settings: { describe: () => Promise.reject(new Error('offline')), openSettingsDocument: openDocument }, }) await failed.load() expect(failed.store.getSnapshot()).toMatchObject({ status: 'unavailable', error: 'offline' }) const rejected = derivedDocumentStore({ - settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openDocument }, + settings: { describe: () => Promise.resolve(describeFailed('provider failed')), openSettingsDocument: openDocument }, }) await rejected.load() expect(rejected.store.getSnapshot()).toMatchObject({ @@ -63,19 +60,16 @@ describe('SettingsDocumentStore', () => { }) it('collapses concurrent open gestures and recovers after a failure', async () => { - let resolveOpen!: (response: RpcResponse<{ opened: true }>) => void - const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) + let resolveOpen!: (response: RemoteResult<{ opened: true }>) => void + const openDocument = vi.fn(() => new Promise>((resolve) => { resolveOpen = resolve })) const controller = derivedDocumentStore({ - settings: { describe: () => Promise.resolve(response(true)), openDocument }, + settings: { describe: () => Promise.resolve(response(true)), openSettingsDocument: openDocument }, }) await controller.load() const first = controller.open() const second = controller.open() expect(openDocument).toHaveBeenCalledOnce() - resolveOpen({ - rpcId: 'settings-open-failed' as never, - result: { ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }, - }) + resolveOpen({ ok: false, error: { code: 'internal', message: 'no default editor', details: {} } }) await Promise.all([first, second]) expect(controller.store.getSnapshot()).toMatchObject({ status: 'ready', opening: false, error: 'no default editor', @@ -87,7 +81,7 @@ describe('SettingsDocumentStore', () => { const controller = derivedDocumentStore({ settings: { describe: vi.fn(() => Promise.resolve(response(true))), - openDocument: () => new Promise((_, reject) => { rejectOpen = reject }), + openSettingsDocument: () => new Promise((_, reject) => { rejectOpen = reject }), }, }) await controller.load() @@ -106,7 +100,7 @@ describe('SettingsDocumentStore', () => { describe: vi.fn() .mockRejectedValueOnce(new Error('offline')) .mockResolvedValueOnce(response(true)), - openDocument: vi.fn(), + openSettingsDocument: vi.fn(), }, } as never const mirror = new SettingsDescribeMirror(wire) diff --git a/packages/client/ui-settings-general/tests/shell.client.spec.ts b/packages/client/ui-settings-general/tests/shell.client.spec.ts index 89da5433ef..4bd9263dec 100644 --- a/packages/client/ui-settings-general/tests/shell.client.spec.ts +++ b/packages/client/ui-settings-general/tests/shell.client.spec.ts @@ -54,7 +54,9 @@ const CHILD_SPECS = { describe('ui-settings apply', () => { it('declares only the slot registry (a pure composition face, no locale)', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'settingsScope']) + expect(inject).toEqual([ + 'slots', 'locale', 'connection', 'remote', 'remote.settings', 'settingsScope', + ]) }) it('registers the shell and declares every child slot, before or after the declaration', async () => { diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 9772bd7a42..8b69e152a0 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: 658b357992a1926f1f21e109c19b7c0975da58ea -README.zh.md: dcc19f80a15e6e7e81c3dea128a999b83e8311ba +README.md: 75efb18daf3e4a96fea637f36633f216046ebccd +README.zh.md: f3c7099e242e379be5143352d4045208a8209dc6 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 658b357992..75efb18daf 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -37,7 +37,7 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot ### Adding and deleting providers -The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks `llm.discoverModels` about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a picker rather than being written, and nothing is written until **Add selected**. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index dcc19f80a1..f3c7099e24 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" ### 新增与删除提供方 -「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**就表单显示的端点询问 `llm.discoverModels`,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 +「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是选择器而非直接写入,只有点击**添加所选**才会写入。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 ### 首次运行弹窗 diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 7045e4831d..4513bde5a2 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -30,6 +30,12 @@ async function bench(isLoopback = true, settings?: object, services: object = {} set: vi.fn(), unset: vi.fn(), }, + llm: { + listProviders: vi.fn(() => Promise.resolve({ ok: true, value: [] })), + listConfigurableProviders: vi.fn(() => Promise.resolve({ ok: true, value: [] })), + discoverModels: vi.fn(() => Promise.resolve({ ok: true, value: [] })), + ...services, + }, // Without a settings face the mirror's reads fail and stay contained; the // Models join itself never fetches until a section actually loads. The real // ui-settings apply also provides the settingsSchema service. @@ -56,7 +62,7 @@ function declare(slots: SlotRegistry): () => void { describe('ui-settings-models apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.settings', + 'slots', 'locale', 'remote', 'remote.credentials', 'remote.llm', 'remote.settings', 'settingsScope', 'settingsSchema', ]) }) @@ -302,11 +308,8 @@ describe('pushed invalidations', () => { }], }, })) - const providers = vi.fn(() => Promise.resolve({ - rpcId: 'apply-models-providers' as never, - result: { ok: true as const, value: { providers: [] } }, - })) - const b = await bench(true, { describe }, { llm: { providers } }) + const listProviders = vi.fn(() => Promise.resolve({ ok: true as const, value: [] })) + const b = await bench(true, { describe }, { listProviders }) declare(b.slots) await b.ctx.plugin({ inject: [...inject], apply }).await() const entry = b.slots.entries('settings.section') diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index da23418375..a22f787a1b 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -4,7 +4,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testi import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, } from '../src/client/ModelsSection.tsx' @@ -133,16 +133,6 @@ function wireNamespaces(): SettingsNamespaceView[] { ] } -let nextRpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } -} -function fail(message: string, code = 'settings-rejected'): RpcResponse { - return { - rpcId: `r-${nextRpc++}` as never, - result: { ok: false, error: { code, message, details: { ns: 'x' } } as never }, - } -} /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -164,17 +154,19 @@ function scriptedFace(overrides: { const unset = overrides.unset ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) const face = { llm: { - providers: vi.fn(() => Promise.resolve(ok({ - providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, - { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, - { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false }, - { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false }, - ], - }))), - models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), + listProviders: vi.fn(() => Promise.resolve(remoteOk([ + { id: 'deepseek-official', name: 'DeepSeek' }, + { id: 'openai', name: 'openai' }, + ]))), + listConfigurableProviders: vi.fn(() => Promise.resolve(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, + { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false }, + { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false }, + { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false }, + { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false }, + ].map(({ active: _active, ...entry }) => entry)))), + discoverModels: vi.fn(() => Promise.resolve(remoteOk([]))), }, settings: { describe: vi.fn(() => Promise.resolve(remoteOk({ writable: true, hasDocument: false, namespaces: wireNamespaces() }))), @@ -315,12 +307,11 @@ describe('ModelsSection', () => { it('skips the draft seat when a refresh drops the dormant row', async () => { const { renderSlot, face, controller } = await mountSection() fireEvent.click(screen.getByRole('button', { name: en.add })) - face.llm.providers.mockImplementation(() => Promise.resolve(ok({ - providers: [ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, - ], - }))) + const directory = [ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true }, + ].map(({ active: _active, ...entry }) => entry) + face.llm.listConfigurableProviders.mockImplementation(() => Promise.resolve(remoteOk(directory))) renderSlot.mockClear() await act(async () => { await controller.load() }) // The draft card is still open while its row is gone from the directory. @@ -448,7 +439,7 @@ describe('ModelsSection', () => { expect(mutate).not.toHaveBeenCalled() // The saved key re-loads the join; the settings answer rides the shared // mirror, so the reload shows as a directory read rather than a describe. - await waitFor(() => { expect(face.llm.providers.mock.calls.length).toBeGreaterThan(1) }) + await waitFor(() => { expect(face.llm.listProviders.mock.calls.length).toBeGreaterThan(1) }) expect((await screen.findByRole('status')).textContent).toBe( providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }), ) @@ -1254,7 +1245,7 @@ describe('ModelsSection', () => { it('renders the load failure with a retry control', async () => { const face = scriptedFace() - face.face.llm.providers = vi.fn(() => Promise.resolve(fail('directory down', 'internal'))) as never + face.face.llm.listProviders = vi.fn(() => Promise.resolve(remoteFail('directory down', 'internal'))) as never const controller = new ModelsSettingsStore( face.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(face.face as never)) await controller.load() diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index d45736cd6d..54b7bdd815 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -3,7 +3,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' -import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog.tsx' import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' @@ -17,10 +17,6 @@ afterEach(() => { document.getElementById('root')?.remove() }) -let nextRpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `onboarding-${nextRpc++}` as never, result: { ok: true, value } } -} /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { return { ok: true as const, value } @@ -91,20 +87,25 @@ function harness(options: { }) const face = { llm: { - providers: () => { + listProviders: () => { if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable')) - return Promise.resolve(ok({ - providers: options.provider === false + return Promise.resolve(remoteOk( + options.provider === false || options.providerActive === false ? [] - : [{ - provider: 'deepseek-official', - displayName: 'DeepSeek', - settingsNs: options.providerSettingsNs ?? 'llm-deepseek', - settingsPath: [], - active: options.providerActive ?? true, - }], - })) + : [{ id: 'deepseek-official', name: 'DeepSeek' }], + )) }, + listConfigurableProviders: () => Promise.resolve(remoteOk( + options.provider === false + ? [] + : [{ + provider: 'deepseek-official', + displayName: 'DeepSeek', + settingsNs: options.providerSettingsNs ?? 'llm-deepseek', + settingsPath: [], + }], + )), + discoverModels: () => Promise.resolve(remoteOk([])), }, settings: { describe: () => Promise.resolve(remoteOk({ diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index e6168398af..0bf6b59861 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -4,7 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime' -import type { JsonValue, RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue, SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' @@ -38,12 +38,11 @@ const PiAiConfig = Schema.object({ })), }) -let nextRpc = 0 -function ok(value: T): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: true, value } } +function ok(value: T) { + return { ok: true as const, value } } -function fail(message: string, code: string): RpcResponse { - return { rpcId: `r-${nextRpc++}` as never, result: { ok: false, error: { code, message, details: {} } as never } } +function fail(message: string, code: string) { + return { ok: false as const, error: { code, message, details: {} } } } /** Credentials answers over the Remote carrier, which has no envelope. */ function remoteOk(value: T) { @@ -88,22 +87,23 @@ function scriptedFace(options: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' }, } const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {}) - const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] }))) + const discover = options.discover ?? vi.fn(() => Promise.resolve(ok([]))) const mutate = options.mutate ?? vi.fn(() => Promise.resolve(remoteOk(namespace))) const set = options.set ?? vi.fn(() => Promise.resolve(remoteOk(undefined))) const face = { llm: { - providers: vi.fn(() => Promise.resolve(ok({ - providers: Object.keys(providers).map(provider => ({ + listProviders: vi.fn(() => Promise.resolve(ok( + Object.keys(providers).map(provider => ({ id: provider, name: provider })), + ))), + listConfigurableProviders: vi.fn(() => Promise.resolve(ok( + Object.keys(providers).map(provider => ({ provider, displayName: provider, settingsNs: 'llm-pi-ai', settingsPath: ['providers', provider], - active: true, declared: options.declaredRoutes?.includes(provider) ?? false, })), - }))), - models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))), + ))), discoverModels: discover, }, settings: { @@ -132,9 +132,9 @@ interface MutateCall { /** The first interrogation payload; fails the case when nothing was asked. */ function firstProbe(discover: ReturnType): unknown { - const call = (discover.mock.calls as unknown as [unknown][])[0]?.[0] + const call = (discover.mock.calls as unknown as [string, Record][])[0] if (call === undefined) throw new Error('no interrogation was recorded') - return call + return { settingsNs: call[0], ...call[1] } } /** @@ -440,7 +440,7 @@ describe('capacity spellings', () => { describe('endpoint interrogation', () => { it('asks the endpoint the form shows, with a key that is not yet stored', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'acme-large', contextWindow: 65_536 }] }))) + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'acme-large', contextWindow: 65_536 }]))) await mountSection({ discover }) openEditor('openai') @@ -460,7 +460,7 @@ describe('endpoint interrogation', () => { }) it('carries the protocol the profile already names', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [] }))) + const discover = vi.fn(() => Promise.resolve(ok([]))) await mountSection({ discover, providers: { openai: { baseURL: 'https://proxy.example/v1', api: 'openai-responses' } }, @@ -479,9 +479,9 @@ describe('endpoint interrogation', () => { }) it('adopts only the picked candidates, keeping a row the user already tuned', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ - models: [{ id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }], - }))) + const discover = vi.fn(() => Promise.resolve(ok([ + { id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }, + ]))) const { mutate } = await mountSection({ discover, providers: { openai: { baseURL: 'https://proxy.example/v1', models: [{ id: 'kept', contextWindow: 111 }] } }, @@ -518,7 +518,7 @@ describe('endpoint interrogation', () => { }) it('reports an empty listing and a rejected transport', async () => { - const empty = vi.fn(() => Promise.resolve(ok({ models: [] }))) + const empty = vi.fn(() => Promise.resolve(ok([]))) await mountSection({ discover: empty }) openEditor('openai') fireEvent.click(screen.getByText(en.fetchModels)) @@ -533,7 +533,7 @@ describe('endpoint interrogation', () => { }) it('can be asked for a configured route even with no endpoint', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'from-registry' }] }))) + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'from-registry' }]))) await mountSection({ discover, providers: { openai: {} } }) openEditor('openai') @@ -585,7 +585,7 @@ describe('endpoint interrogation', () => { }) it('closes the picker without adopting anything on cancel', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ models: [{ id: 'fresh' }] }))) + const discover = vi.fn(() => Promise.resolve(ok([{ id: 'fresh' }]))) const { mutate } = await mountSection({ discover }) openEditor('openai') @@ -599,9 +599,9 @@ describe('endpoint interrogation', () => { }) it('toggles a candidate off and back on before adopting', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ - models: [{ id: 'a' }, { id: 'b', maxTokens: 2048 }], - }))) + const discover = vi.fn(() => Promise.resolve(ok([ + { id: 'a' }, { id: 'b', maxTokens: 2048 }, + ]))) const { mutate } = await mountSection({ discover }) openEditor('openai') @@ -620,9 +620,9 @@ describe('endpoint interrogation', () => { }) it('selects and clears every discovered candidate in one action', async () => { - const discover = vi.fn(() => Promise.resolve(ok({ - models: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], - }))) + const discover = vi.fn(() => Promise.resolve(ok([ + { id: 'a' }, { id: 'b' }, { id: 'c' }, + ]))) await mountSection({ discover }) openEditor('openai') @@ -664,15 +664,12 @@ describe('provider rows', () => { it('shows no tag when the adapter draws no catalog distinction', async () => { const scripted = scriptedFace({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } }) - scripted.face.llm.providers = vi.fn(() => Promise.resolve(ok({ - providers: [{ - provider: 'openai', - displayName: 'openai', - settingsNs: 'llm-pi-ai', - settingsPath: ['providers', 'openai'], - active: true, - }], - }))) as never + scripted.face.llm.listConfigurableProviders = vi.fn(() => Promise.resolve(ok([{ + provider: 'openai', + displayName: 'openai', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai'], + }]))) as never const controller = new ModelsSettingsStore( scripted.face as unknown as WireFace, settingsSchema, new SettingsDescribeMirror(scripted.face as never)) await controller.load() @@ -828,16 +825,13 @@ describe('hand-declared providers', () => { }) // The reload after the write answers with the renamed route, exactly as // the adapter re-registers it. - face.llm.providers = vi.fn(() => Promise.resolve(ok({ - providers: [{ - provider: 'acme-gateway', - displayName: 'Acme 网关', - settingsNs: 'llm-pi-ai', - settingsPath: ['providers', 'acme-gateway'], - active: true, - declared: true, - }], - }))) + face.llm.listConfigurableProviders = vi.fn(() => Promise.resolve(ok([{ + provider: 'acme-gateway', + displayName: 'Acme 网关', + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'acme-gateway'], + declared: true, + }]))) openEditor('acme-gateway') fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } }) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 677ca14418..8ce1724b32 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -58,10 +58,33 @@ function api(overrides: { describeCredentials?: (refs: readonly string[]) => Promise>> } = {}) { const seenRefs: string[][] = [] + const providers = overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))) + let providerBatch: Promise> | undefined + let providerBatchReads = 0 + const readProviderBatch = (): Promise> => { + providerBatch ??= providers() + const current = providerBatch + providerBatchReads += 1 + if (providerBatchReads % 2 === 0) providerBatch = undefined + return current + } + const mapProviderBatch = async ( + project: (rows: typeof DIRECTORY) => T, + ): Promise> => { + const response = await readProviderBatch() + return response.result.ok + ? remoteOk(project(response.result.value.providers)) + : remoteFail(response.result.error.message) + } const face = { llm: { - providers: overrides.providers ?? (() => Promise.resolve(ok({ providers: DIRECTORY }))), - models: () => Promise.resolve(ok({ groups: [], failures: [] })), + listProviders: () => mapProviderBatch(rows => rows + .filter(row => row.active) + .map(row => ({ id: row.provider, name: row.displayName }))), + listConfigurableProviders: () => mapProviderBatch(rows => rows + .filter(row => row.settingsNs !== '') + .map(({ active: _active, ...row }) => row)), + discoverModels: () => Promise.resolve(remoteOk([])), }, settings: { describe: overrides.describeSettings diff --git a/packages/client/ui-skill/README.i18n.yaml b/packages/client/ui-skill/README.i18n.yaml index 0e5bc155d8..acedab1603 100644 --- a/packages/client/ui-skill/README.i18n.yaml +++ b/packages/client/ui-skill/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-skill/README.md -README.md: 7d43f0a28010b68cf46b43376c36895e3d74f66b -README.zh.md: 846f1703ecf3460a370954655cee66a9c67acf21 +README.md: de564bd533f6cec83bb0a55e6ebfa687fa716ee9 +README.zh.md: 2408a29b83befd68785f7e7feccc588b6372a6fb diff --git a/packages/client/ui-skill/README.md b/packages/client/ui-skill/README.md index 7d43f0a280..de564bd533 100644 --- a/packages/client/ui-skill/README.md +++ b/packages/client/ui-skill/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-ui-skill` lets users invoke skills by typing `/name` in the composer: the suggestion menu offers user-invocable skills from the `skill.list` RPC, and a pick lands the literal `/name ` text that the host then loads as the skill's instructions. Loading is deterministic: the host's pre-step boundary (`dsh-tool-skill`) recognizes the whitespace-bounded `/name` token in the sent message and injects the rendered `` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. Settled skill calls render in the conversation as an expandable `Instructions` card, derived only from the frozen call/result slice. +`dsh-client-ui-skill` lets users invoke skills by typing `/name` in the composer: the suggestion menu offers user-invocable skills from the `skills/list` Remote, and a pick lands the literal `/name ` text that the host then loads as the skill's instructions. Loading is deterministic: the host's pre-step boundary (`dsh-tool-skill`) recognizes the whitespace-bounded `/name` token in the sent message and injects the rendered `` for every entry point, so a menu pick, a hand-typed token, and a TUI/ACP prompt all load the skill the same way. Settled skill calls render in the conversation as an expandable `Instructions` card, derived only from the frozen call/result slice. ## Table of Contents @@ -29,7 +29,7 @@ Type `/` in the composer and pick a skill from the suggestions, or type `/name` ### What the source offers -Ordinary-session candidates come from the `skill.list` RPC; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skill.list` is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. +Ordinary-session candidates come from the `skills/list` Remote; the host serves every user-invocable skill, and a `modelInvocable: false` entry (a `disable-model-invocation` skill, whose only entry point is this path) wears the user-only marker as a description prefix in the active language. Results filter by `startsWith(query)`. A failed `skills/list` call is logged and folded into a silent menu-group drop — the menu shows only pending/ready states. ### The skill tool row diff --git a/packages/client/ui-skill/README.zh.md b/packages/client/ui-skill/README.zh.md index 846f1703ec..2408a29b83 100644 --- a/packages/client/ui-skill/README.zh.md +++ b/packages/client/ui-skill/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-ui-skill` 让用户通过在编辑器中键入 `/name` 来调用 skill:建议菜单从 `skill.list` RPC 提供用户可调用的 skill 候选,选择一项会落下字面文本 `/name `,宿主随后将其加载为 skill 的指令。加载是确定性的:宿主的 pre-step 边界(`dsh-tool-skill`)识别发出消息中以空白为界的 `/name` token,并为每个入口注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。已结算的 skill 调用在对话中渲染为可展开的 `Instructions` 卡片,只从冻结的调用/结果切片派生。 +`dsh-client-ui-skill` 让用户通过在编辑器中键入 `/name` 来调用 skill:建议菜单从 `skills/list` Remote 提供用户可调用的 skill 候选,选择一项会落下字面文本 `/name `,宿主随后将其加载为 skill 的指令。加载是确定性的:宿主的 pre-step 边界(`dsh-tool-skill`)识别发出消息中以空白为界的 `/name` token,并为每个入口注入渲染后的 ``,因此菜单 pick、手动键入的 token 与 TUI/ACP 提示词都以同一种方式加载 skill。已结算的 skill 调用在对话中渲染为可展开的 `Instructions` 卡片,只从冻结的调用/结果切片派生。 ## 目录 @@ -29,7 +29,7 @@ kind: "package-reference" ### source 提供什么 -普通会话的候选来自 `skill.list` RPC;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skill.list` 失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 +普通会话的候选来自 `skills/list` Remote;宿主提供每一个用户可调用的 skill,`modelInvocable: false` 的条目(即 `disable-model-invocation` skill,此路径是其唯一入口)会以当前语言把仅限用户标记作为描述前缀带上。结果按 `startsWith(query)` 过滤。`skills/list` 调用失败时会被记录并静默丢弃该菜单组——菜单只显示 pending/ready 状态。 ### skill 工具行 diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index c4f2e3ea63..208baac3bc 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -27,11 +27,7 @@ type SkillRow = { name: string; description: string; whenToUse?: string; modelIn type ListResult = | { ok: true; value: { skills: SkillRow[] } } | { ok: false; error: { code: string; message: string; details: object } } -type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }> -type InvokeResult = - | { ok: true; value: { accepted: true } } - | { ok: false; error: { code: string; message: string; details: object } } -type InvokeFn = (payload: object) => Promise<{ result: InvokeResult }> +type ListFn = (payload: object, signal?: AbortSignal) => Promise interface PresentationCapture { slots: SlotRegistry @@ -63,18 +59,17 @@ function providePresentation(ctx: Context): PresentationCapture { } /** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */ -async function bench(list: ListFn, addressed?: SessionId, invoke?: InvokeFn) { +async function bench(list: ListFn, addressed?: SessionId) { const ctx = new Context() let captured: InputTriggerSource | undefined ctx.provide('inputTriggers', { registerSource: (src: InputTriggerSource) => { captured = src; return () => {} } }) - const defaultInvoke: InvokeFn = () => Promise.resolve({ result: { ok: true as const, value: { accepted: true as const } } }) - ctx.provide('connection', { api: { skills: { list, invoke: invoke ?? defaultInvoke } } }) + ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: (id: SessionId) => id === addressed ? { parentSessionId: sid('parent'), childSessionId: id, mode: 'continuable' as const } : undefined, }) - const remote = new TestRemote(ctx) + const remote = new TestRemote(ctx, { skills: { list } }) providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() return { ctx, source: captured!, remote } @@ -86,7 +81,7 @@ const CATALOG: SkillRow[] = [ { name: 'deploy', description: 'deploy flow', modelInvocable: true }, ] -const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } }) +const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ ok: true as const, value: { skills } }) /** Counting fake: records payloads, resolves the shared catalog. */ function countingList(skills: SkillRow[] = CATALOG) { @@ -113,9 +108,9 @@ describe('apply', () => { it('registers the dedicated skill row and its locale dictionaries', async () => { const ctx = new Context() ctx.provide('inputTriggers', { registerSource: () => () => {} }) - ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) + ctx.provide('connection', {}) ctx.provide('sessions', { subagentAddress: () => undefined }) - new TestRemote(ctx) + new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) await ctx.plugin({ inject: [...inject], apply }).await() const entry = presentation.slots.entries('tool.call.toolview')[0] @@ -151,8 +146,8 @@ describe('apply', () => { // InputTriggerService itself injects 'sessions'; the stub unblocks its fiber. ctx.provide('sessions', {}) await ctx.plugin(InputTriggerService).await() - ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } }) - new TestRemote(ctx) + ctx.provide('connection', {}) + new TestRemote(ctx, { skills: { list: listOk(CATALOG) } }) const presentation = providePresentation(ctx) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -188,10 +183,10 @@ describe('candidates: sessionId addressing', () => { it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => { const { source } = await bench(() => Promise.resolve({ - result: { ok: false, error: { code: 'internal', message: 'boom', details: {} } }, + ok: false, error: { code: 'internal', message: 'boom', details: {} }, })) await expect(source.candidates(proj('s1'), req('co'))) - .rejects.toThrow('skill.list failed: internal: boom') + .rejects.toThrow('skills/list failed: internal: boom') }) it('does not fetch Agent-bound skills for an addressed child', async () => { @@ -248,7 +243,7 @@ describe('catalog cache', () => { const { source } = await bench((payload) => { payloads.push(payload) return fail - ? Promise.resolve({ result: { ok: false as const, error: { code: 'internal', message: 'boom', details: {} } } }) + ? Promise.resolve({ ok: false as const, error: { code: 'internal', message: 'boom', details: {} } }) : listOk(CATALOG)(payload) }) await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('boom') diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index dc37fd7c14..3cb8c2adbd 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -6,12 +6,6 @@ import type { import type { IWorkspaces, WorkspaceId, WorkspaceSnapshot, WorkspaceView, } from '@deepseek-ai/dsh-api-workspace-controller/client' -import { - RpcId, - type IApiClient, - type RpcError, - type RpcResponse, -} from '@deepseek-ai/dsh-client-connection/client' import type { ClientRemote, DirectoryListing } from '@deepseek-ai/dsh-api-remotes/client' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { SessionId } from '@deepseek-ai/dsh-session/types' @@ -153,16 +147,6 @@ class FakeWorkspaces implements IWorkspaces { } } -let nextRpcId = 0 - -function ok(value: T): RpcResponse { - return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: true, value } } -} - -function failed(error: RpcError): RpcResponse { - return { rpcId: RpcId(`workspace-test-${nextRpcId++}`), result: { ok: false, error } } -} - const listing: DirectoryListing = { path: '/home/u', home: '/home/u', @@ -171,38 +155,6 @@ const listing: DirectoryListing = { truncated: false, } -class FakeApiClient implements IApiClient { - readonly calls: Array<{ readonly method: string; readonly payload: unknown }> = [] - - onDescribe: IApiClient['host']['describe'] = () => Promise.resolve(ok({ - version: 'test', - cwd: '/home/u', - attachedSessions: 0, - home: '/home/u', - canOpenPath: true, - })) - onOpenPath: IApiClient['host']['openPath'] = () => Promise.resolve(ok({ opened: true })) - - declare readonly skills: IApiClient['skills'] - declare readonly agentPresets: IApiClient['agentPresets'] - declare readonly settings: IApiClient['settings'] - declare readonly llm: IApiClient['llm'] - - readonly host: IApiClient['host'] = { - describe: (payload, signal) => this.record('host.describe', payload, this.onDescribe(payload, signal)), - openPath: (payload, signal) => this.record('host.openPath', payload, this.onOpenPath(payload, signal)), - } - - callsOf(method: string): unknown[] { - return this.calls.filter(call => call.method === method).map(call => call.payload) - } - - private record(method: string, payload: unknown, response: Promise): Promise { - this.calls.push({ method, payload }) - return response - } -} - /** The directory-picking Remote namespace, recorded and scripted per case. */ class FakeDirectoryPicker { readonly calls: { method: string; payload: unknown }[] = [] @@ -236,18 +188,16 @@ interface BenchOptions { function bench(options: BenchOptions = {}) { const ctx = new Context() - const api = new FakeApiClient() const directoryPicker = new FakeDirectoryPicker() const workspaces = new FakeWorkspaces(options.workspaces ?? workspaceState([], [], 'pending')) const sessions = new FakeSessions(options.sessions ?? sessionState([], undefined, 'pending')) const uiWorkspace = new UiWorkspaceService( ctx, - api, directoryPicker.remote, workspaces, sessions as unknown as ISessions, ) - return { api, ctx, directoryPicker, sessions, uiWorkspace, workspaces } + return { ctx, directoryPicker, sessions, uiWorkspace, workspaces } } async function flush(): Promise { @@ -484,9 +434,6 @@ describe('UiWorkspaceService', () => { expect(b.directoryPicker.callsOf('list')).toEqual([{ path: undefined }, { path: '/home/u' }]) await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).resolves.toBe('/home/u/new') expect(b.directoryPicker.callsOf('createDirectory')).toEqual([{ path: '/home/u', name: 'new' }]) - await expect(b.uiWorkspace.openPath('/w/alpha/file.ts')).resolves.toBeUndefined() - expect(b.api.callsOf('host.openPath')).toEqual([{ path: '/w/alpha/file.ts' }]) - b.directoryPicker.onPick = () => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no chooser', details: {} }, }) @@ -503,7 +450,5 @@ describe('UiWorkspaceService', () => { await expect(b.uiWorkspace.createDirectory('/home/u', 'new')).rejects.toMatchObject({ rpcError: { code: 'directory-exists' }, }) - b.api.onOpenPath = () => Promise.resolve(failed({ code: 'internal', message: 'boom', details: {} })) - await expect(b.uiWorkspace.openPath('/missing')).rejects.toThrow('path open failed: boom') }) }) diff --git a/packages/context/file-reference/README.i18n.yaml b/packages/context/file-reference/README.i18n.yaml index 611627675c..5c7142368a 100644 --- a/packages/context/file-reference/README.i18n.yaml +++ b/packages/context/file-reference/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/context/file-reference/README.md -README.md: 1f4e04a8c3f78feefd86e7e90096d2c7f27e86cb -README.zh.md: 5b30a9950dd6920bd3e951224d743e4c68698e0e +README.md: 7571aecbc42cdcda39a2c7ad5416205e7f4c108c +README.zh.md: ad13336f47b3069fd35e75eb2aed994221bb9e3c diff --git a/packages/context/file-reference/README.md b/packages/context/file-reference/README.md index 1f4e04a8c3..7571aecbc4 100644 --- a/packages/context/file-reference/README.md +++ b/packages/context/file-reference/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Host-backed user interfaces use `dsh-file-reference` to offer `@file` completion: a UI asks for path candidates for the addressed agent, the model types `@path` or `@"path with spaces"`, and picking a candidate inserts the matching mention as ordinary prompt text. The seam itself owns no filesystem access — a concrete provider such as `@deepseek-ai/dsh-file-reference-local` supplies candidates, ranking, caching, and invalidation. Selecting a candidate never reads or attaches file contents; the model must call a filesystem tool to inspect a file. The same discovery is callable from browser consumers through the remote `fileReferences/list` method without an API Proxy route. +Host-backed user interfaces use `dsh-file-reference` to offer `@file` completion: a UI asks for path candidates for the addressed agent, the model types `@path` or `@"path with spaces"`, and picking a candidate inserts the matching mention as ordinary prompt text. The seam itself owns no filesystem access — a concrete provider such as `@deepseek-ai/dsh-file-reference-local` supplies candidates, ranking, caching, and invalidation. Selecting a candidate never reads or attaches file contents; the model must call a filesystem tool to inspect a file. Session Controller exposes the same discovery to browser consumers through the `fileReferences/list` Remote. ## Table of Contents @@ -33,7 +33,7 @@ An `@path` token at the start of input or after whitespace triggers completion; ### Getting candidates -`ctx.fileReferences.list(agent, query, signal)` returns path-only file and directory candidates for one agent's working directory, deterministically ranked by the provider. Directory mentions render with a trailing `/` so completion can descend another level. Browser consumers call the same discovery as `ctx.remote.fileReferences.list`; the reserved trailing signal cancels a slow autocomplete. +`ctx.fileReferences.list(agent, query, signal)` returns path-only file and directory candidates for one agent's working directory, deterministically ranked by the provider. Directory mentions render with a trailing `/` so completion can descend another level. Browser consumers call the Session Controller adapter as `ctx.remote.fileReferences.list`; the trailing signal cancels a slow autocomplete. ### Pairing with a provider @@ -51,13 +51,13 @@ This section explains the design of the seam; the observable behavior is covered ### Design concept -The package is one separation: an abstract discovery service plus a shared, browser-safe mention grammar, with providers owning namespace access, ranking, caching, and invalidation. The service is a `TypertRemoteService` whose `list` contract is remotely callable as the unary `fileReferences/list` method, so the same seam serves in-process and browser consumers. +The package separates an abstract discovery service from a shared, browser-safe mention grammar, with providers owning namespace access, ranking, caching, and invalidation. The service remains wire-neutral; `dsh-api-session-controller` owns the `fileReferences/list` Remote adapter and delegates to the active provider after resolving its Agent. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | Abstract `FileReferenceService`, `FILE_REFERENCE_PROMPT`, remote `list` face | +| [`src/index.ts`](src/index.ts) | Abstract `FileReferenceService` and `FILE_REFERENCE_PROMPT` | | [`src/grammar.ts`](src/grammar.ts) | `activeAtToken` recognition and `formatFileMention` rendering | | [`src/types.ts`](src/types.ts) | `FileReferenceCandidate` path-only result type | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the discovery contract | diff --git a/packages/context/file-reference/README.zh.md b/packages/context/file-reference/README.zh.md index 5b30a9950d..ad13336f47 100644 --- a/packages/context/file-reference/README.zh.md +++ b/packages/context/file-reference/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -宿主驱动 UI 使用 `dsh-file-reference` 提供 `@file` 补全:UI 为指定 agent 请求路径候选,模型输入 `@path` 或 `@"path with spaces"`,选中候选后,匹配的 mention 作为普通提示词文本插入。seam 本身不拥有文件系统访问——具体提供方(如 `@deepseek-ai/dsh-file-reference-local`)负责提供候选、排序、缓存与失效。选中候选绝不读取或附带文件内容;模型必须调用文件系统工具才能查看文件。浏览器消费方无需 API Proxy 路由,即可通过远程 `fileReferences/list` 方法调用同一发现能力。 +宿主驱动 UI 使用 `dsh-file-reference` 提供 `@file` 补全:UI 为指定 agent 请求路径候选,模型输入 `@path` 或 `@"path with spaces"`,选中候选后,匹配的 mention 作为普通提示词文本插入。seam 本身不拥有文件系统访问——具体提供方(如 `@deepseek-ai/dsh-file-reference-local`)负责提供候选、排序、缓存与失效。选中候选绝不读取或附带文件内容;模型必须调用文件系统工具才能查看文件。Session Controller 通过 `fileReferences/list` Remote 向浏览器消费方暴露同一发现能力。 ## 目录 @@ -33,7 +33,7 @@ kind: "package-reference" ### 获取候选 -`ctx.fileReferences.list(agent, query, signal)` 返回指定 agent 工作目录中仅含路径的文件与目录候选,由提供方确定性地排序。目录 mention 呈现时带尾随 `/`,使补全可以继续深入下一层。浏览器消费方通过 `ctx.remote.fileReferences.list` 调用同一发现能力;保留的末位 signal 参数可取消慢速自动补全。 +`ctx.fileReferences.list(agent, query, signal)` 返回指定 agent 工作目录中仅含路径的文件与目录候选,由提供方确定性地排序。目录 mention 呈现时带尾随 `/`,使补全可以继续深入下一层。浏览器消费方通过 Session Controller adapter 的 `ctx.remote.fileReferences.list` 调用同一发现能力;末位 signal 参数可取消慢速自动补全。 ### 搭配提供方 @@ -51,13 +51,13 @@ kind: "package-reference" ### 设计理念 -本包建立在一个分离上:抽象发现服务加共享、浏览器安全的 mention 语法,由提供方负责命名空间访问、排序、缓存与失效。该服务是 `TypertRemoteService`,其 `list` 约定可通过一元 `fileReferences/list` 方法远程调用,因此同一 seam 同时服务进程内与浏览器消费方。 +本包把抽象发现服务与共享、浏览器安全的 mention 语法分开,由提供方负责命名空间访问、排序、缓存与失效。该服务保持 wire 中立;`dsh-api-session-controller` 持有 `fileReferences/list` Remote adapter,并在解析 Agent 后委派给当前 provider。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | 抽象 `FileReferenceService`、`FILE_REFERENCE_PROMPT`、远程 `list` 面 | +| [`src/index.ts`](src/index.ts) | 抽象 `FileReferenceService` 与 `FILE_REFERENCE_PROMPT` | | [`src/grammar.ts`](src/grammar.ts) | `activeAtToken` 识别与 `formatFileMention` 渲染 | | [`src/types.ts`](src/types.ts) | 仅含路径的结果类型 `FileReferenceCandidate` | | [`src/invariant.ts`](src/invariant.ts) | 发现约定的不变式伴生插件 | diff --git a/packages/context/file-reference/tests/service.spec.ts b/packages/context/file-reference/tests/service.spec.ts index 1f41ad6f79..388af70458 100644 --- a/packages/context/file-reference/tests/service.spec.ts +++ b/packages/context/file-reference/tests/service.spec.ts @@ -1,4 +1,4 @@ -/** The Remote face delegates to the provider's discovery contract unchanged. */ +/** The abstract service preserves the provider's discovery contract. */ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -6,7 +6,7 @@ import { FileReferenceService } from '../src/index.ts' import type { FileReferenceCandidate } from '../src/types.ts' describe('FileReferenceService', () => { - it('serves the Remote face through the abstract discovery member', async () => { + it('registers a provider implementation without wrapping its discovery member', async () => { const candidates: FileReferenceCandidate[] = [{ path: 'src', kind: 'directory' }] const list = vi.fn((_agent: Agent, _query: string, _signal: AbortSignal) => Promise.resolve(candidates)) class StubProvider extends FileReferenceService { @@ -15,7 +15,7 @@ describe('FileReferenceService', () => { const provider = new StubProvider(new Context()) const agent = { id: 'target' } as unknown as Agent const signal = new AbortController().signal - await expect(provider.remoteExportList(agent, 'sr', signal)).resolves.toBe(candidates) + await expect(provider.list(agent, 'sr', signal)).resolves.toBe(candidates) expect(list).toHaveBeenCalledWith(agent, 'sr', signal) }) }) diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index 633c553557..a4f182a645 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -348,11 +348,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'path', description: 'existing parent directory.' }, { name: 'name', description: 'child directory name.' }], returns: 'created absolute path.', }, - { - signature: 'openPath(path: string): Promise', - description: 'Open a path with the Host operating system.', - parameters: [{ name: 'path', description: 'absolute or Host-resolvable path.' }], - }, ], }, { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 964f83ad24..4891cd5537 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -1625,10 +1625,10 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Owner share of one provider-card extension occurrence. */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card\'s directory row (route id, display name, settings address, live state). */\n provider: ConfigurableProviderView\n /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */\n configured: boolean\n /** Whether the row\'s referenced api-key credential is confirmed configured (the page\'s credential join). */\n keyConfigured: boolean\n}', + '/** Owner share of one provider-card extension occurrence. */\nexport interface ProviderCardExtrasOwnerProps {\n /** The card\'s directory row (route id, display name, settings address, live state). */\n provider: ProviderDirectoryEntry\n /** Whether any layer configures this provider (its profile resolves); `false` while the add-provider draft edits a dormant row. */\n configured: boolean\n /** Whether the row\'s referenced api-key credential is confirmed configured (the page\'s credential join). */\n keyConfigured: boolean\n}', ], ownerPropsReferences: [ - 'ConfigurableProviderView', + 'ProviderDirectoryEntry', ], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 00f2992f94..eb1992f66d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -854,12 +854,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'deterministic path-only candidates.', }, - { - signature: '@Remote(\'list\') remoteExportList( agent: Agent, query: string, signal: AbortSignal, ): Promise', - description: 'Remote face of list; the decorator cannot mark the abstract member, so this concrete adapter carries the identical contract.', - parameters: [{ name: 'agent', description: 'target agent whose session cwd bounds discovery.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], - returns: 'deterministic path-only candidates.', - }, ], }, { @@ -1118,7 +1112,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the disposer, carrying {@link AdapterRegistrationHandle.replace}.', }, { - signature: 'listProviders(): LlmProviderInfo[]', + signature: '@Remote listProviders(): LlmProviderInfo[]', description: 'Describe provider routes with a registered adapter.', parameters: [], returns: 'detached provider metadata in registration order.', @@ -1130,23 +1124,30 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'a handle that withdraws all of them, and can atomically replace them.', }, { - signature: 'listConfigurableProviders(): LlmConfigurableProvider[]', + signature: '@Remote listConfigurableProviders(): LlmConfigurableProvider[]', description: 'List every declared configurable provider, registered or dormant.', parameters: [], returns: 'detached directory entries in declaration order.', }, { - signature: 'registerModelDiscovery( settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise, ): () => void', + signature: 'registerModelDiscovery( settingsNs: string, discover: ( request: LlmModelDiscoveryRequest, signal?: AbortSignal, ) => Promise, ): () => void', description: 'Offer to interrogate provider endpoints on behalf of the settings namespace this plugin owns. The namespace is the key because that is what a configuration surface already holds from the configurable-provider directory, and because a provider being *added* has no route to name yet. Disposed with the fiber.', - parameters: [{ name: 'settingsNs', description: 'the namespace whose profiles this discovery serves.' }, { name: 'discover', description: 'interrogates one endpoint; must honor `request.signal`.' }], + parameters: [{ name: 'settingsNs', description: 'the namespace whose profiles this discovery serves.' }, { name: 'discover', description: 'interrogates one endpoint and must honor the supplied signal.' }], returns: 'the disposer that withdraws the offer.', }, { - signature: 'async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, ): Promise', + signature: 'async discoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal, ): Promise', description: 'Interrogate one provider endpoint for the models it advertises. The request describes a draft, not a stored route, so nothing here reads or writes settings or credentials — the caller owns both, and the reply is candidate metadata a surface may offer for adoption.', - parameters: [{ name: 'settingsNs', description: 'namespace whose registered discovery serves this draft.' }, { name: 'request', description: 'the endpoint, protocol, and one-shot credential to use.' }], + parameters: [{ name: 'settingsNs', description: 'namespace whose registered discovery serves this draft.' }, { name: 'request', description: 'the endpoint, protocol, and one-shot credential to use.' }, { name: 'signal', description: 'caller cancellation.' }], returns: 'the advertised models, deduplicated in endpoint order.', }, + { + signature: '@Remote(\'discoverModels\') async remoteDiscoverModels( settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal, ): Promise', + description: 'Remote adapter for one draft provider interrogation.', + parameters: [{ name: 'settingsNs', description: 'namespace whose registered discovery serves this draft.' }, { name: 'request', description: 'endpoint, protocol, and one-shot credential to use.' }, { name: 'signal', description: 'caller cancellation supplied by the Remote carrier.' }], + returns: 'advertised models in endpoint order.', + throws: ['TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails.'], + }, { signature: 'providerRetryPolicy(provider: string): ResolvedRetryPolicy', description: 'Resolve the retry policy captured when one provider route was registered.', @@ -1375,6 +1376,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'request', description: 'Session identity and requested model selection.' }], returns: 'the normalized selection installed for the Session.', }, + { + signature: '@Remote(\'modelCatalog\') modelCatalog(): Promise', + description: 'Describe every currently routable model for Host-generation selectors.', + parameters: [], + returns: 'provider-grouped models, the deployment default, and isolated provider failures.', + }, + { + signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', + description: 'Open a path resolved against one Session\'s workspace on the Host desktop.', + parameters: [{ name: 'request', description: 'Session identity and absolute or workspace-relative path.' }, { name: 'signal', description: 'caller lifetime; abort terminates inspection or the native command.' }], + returns: 'confirmation after the native opener accepts the path.', + throws: ['TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails.'], + }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', description: 'Rename one Session after explicitly resuming it.', @@ -1431,6 +1445,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionFileReferences', + summary: 'Host Remote adapter over the composed file-reference provider.', + description: 'Host Remote adapter over the composed file-reference provider.', + methods: [ + { + signature: '@Remote list( agent: Agent, query: string, signal: AbortSignal, ): Promise', + description: 'List file and directory candidates for one Agent\'s working directory.', + parameters: [{ name: 'agent', description: 'target Agent resolved from the Session identity on the wire.' }, { name: 'query', description: 'path text following `@` or `@"`.' }, { name: 'signal', description: 'caller cancellation.' }], + returns: 'deterministic path-only candidates from the composed provider.', + }, + ], + }, { key: 'sessionPersistence', summary: 'Durable append-only session storage.', @@ -1802,6 +1829,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionSkillCatalog', + summary: 'Host service backing `ctx.remote.skills` without activating a cold Agent.', + description: 'Host service backing `ctx.remote.skills` without activating a cold Agent.', + methods: [ + { + signature: '@Remote async list(request: SkillListRequest, signal: AbortSignal): Promise', + description: 'List the user-invocable skills visible to one Session composition.', + parameters: [{ name: 'request', description: 'Session identity whose cwd and preset select the catalog view.' }, { name: 'signal', description: 'caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics.' }], + returns: 'user-invocable skill metadata without loading skill bodies.', + throws: ['TypertRemoteFailure when the Session cannot be inspected or no registry can serve it.'], + }, + ], + }, { key: 'sessionTelemetry', summary: 'Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.', @@ -1946,6 +1987,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'the namespace\'s redacted view after the write.', throws: ['TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.'], }, + { + signature: '@Remote async openSettingsDocument(signal: AbortSignal): Promise', + description: 'Materialize the provider-owned settings document and open it in a native text editor.', + parameters: [{ name: 'signal', description: 'caller lifetime; abort terminates preparation or the native command.' }], + returns: 'confirmation after the native opener accepts the document.', + throws: ['TypertRemoteFailure when no document exists, preparation fails, or opening fails.'], + }, + { + signature: '@Remote async openAgentPresetDirectory( agentPreset: string, signal: AbortSignal, ): Promise', + description: 'Open one user-authored Agent preset directory or return its path when no native opener exists.', + parameters: [{ name: 'agentPreset', description: 'preset id resolved against Host-owned roots.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }], + returns: 'an opened confirmation or the resolved directory for text display.', + throws: ['TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened.'], + }, ], }, { @@ -3355,6 +3410,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentPreset', declaration: 'export interface AgentPreset {\n readonly id: string;\n readonly trust: PresetTrust;\n readonly path: string;\n readonly name?: string;\n readonly description?: string;\n readonly order?: number;\n readonly broken?: string;\n}', }, + { + name: 'AgentPresetDirectoryOpenValue', + declaration: 'export type AgentPresetDirectoryOpenValue = {\n readonly opened: true;\n} | {\n readonly opened: false;\n readonly path: string;\n};', + }, { name: 'AgentPresetDocument', declaration: 'export interface AgentPresetDocument {\n readonly agentPreset: string;\n readonly trust: PresetTrust;\n readonly content: string;\n readonly name?: string;\n readonly description?: string;\n}', @@ -4221,7 +4280,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelDiscoveryRequest', - declaration: 'export interface LlmModelDiscoveryRequest {\n provider?: string;\n baseURL?: string;\n api?: string;\n apiKey?: string;\n signal?: AbortSignal;\n}', + declaration: 'export interface LlmModelDiscoveryRequest {\n provider?: string;\n baseURL?: string;\n api?: string;\n apiKey?: string;\n}', }, { name: 'LlmModelInfo', @@ -4245,7 +4304,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmRuntime', - declaration: 'export class LlmRuntime extends Service {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', + declaration: 'export class LlmRuntime extends TypertRemoteService {\n constructor(ctx: Context);\n registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle;\n @Remote\n listProviders(): LlmProviderInfo[];\n registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle;\n @Remote\n listConfigurableProviders(): LlmConfigurableProvider[];\n registerModelDiscovery(settingsNs: string, discover: (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise): () => void;\n async discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal?: AbortSignal): Promise;\n @Remote(\'discoverModels\')\n async remoteDiscoverModels(settingsNs: string, request: LlmModelDiscoveryRequest, signal: AbortSignal): Promise;\n providerRetryPolicy(provider: string): ResolvedRetryPolicy;\n imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined;\n async listModels(provider: string): Promise;\n async resolveModelInfo(provider: string, model: string, signal?: AbortSignal): Promise;\n async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise;\n async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise;\n stream(options: GenerateOptions): AsyncIterable;\n}', }, { name: 'LspHover', @@ -4383,6 +4442,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n}', }, + { + name: 'ModelCatalog', + declaration: 'export interface ModelCatalog {\n readonly default: ModelSelection;\n readonly routableProviders: readonly string[];\n readonly groups: readonly ModelProviderGroup[];\n readonly failures: readonly ModelCatalogFailure[];\n}', + }, + { + name: 'ModelCatalogFailure', + declaration: 'export interface ModelCatalogFailure {\n readonly id: string;\n readonly name: string;\n readonly message: string;\n}', + }, + { + name: 'ModelCatalogModel', + declaration: 'export interface ModelCatalogModel {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n readonly reasoning?: ModelReasoning;\n}', + }, { name: 'ModelMessageSource', declaration: 'export interface ModelMessageSource extends AssistantProvenance {\n kind: \'model\';\n}', @@ -4395,6 +4466,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelModalityMap', declaration: 'export interface ModelModalityMap {\n text: \'text\';\n image: \'image\';\n}', }, + { + name: 'ModelProviderGroup', + declaration: 'export interface ModelProviderGroup {\n readonly id: string;\n readonly name: string;\n readonly models: readonly ModelCatalogModel[];\n}', + }, + { + name: 'ModelReasoning', + declaration: 'export interface ModelReasoning {\n readonly efforts: readonly ModelReasoningEffort[];\n readonly defaultEffort?: string;\n}', + }, + { + name: 'ModelReasoningEffort', + declaration: 'export interface ModelReasoningEffort {\n readonly id: string;\n readonly name: string;\n readonly description?: string;\n}', + }, { name: 'ObjectJsonSchema', declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};', @@ -4589,7 +4672,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'model-discovery-failed\': {\n settingsNs: string;\n baseURL?: string;\n };\n \'internal\': {};\n}', + declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'internal\': {};\n}', }, { name: 'RpcId', @@ -4875,6 +4958,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionObservationOptions', declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}', }, + { + name: 'SessionOpenWorkspacePathRequest', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly sessionId: SessionId;\n readonly path: string;\n}', + }, + { + name: 'SessionOpenWorkspacePathValue', + declaration: 'export interface SessionOpenWorkspacePathValue {\n readonly opened: true;\n}', + }, { name: 'SessionPage', declaration: 'export interface SessionPage {\n readonly records: readonly SessionHistoryRecord[];\n readonly hasMore: boolean;\n}', @@ -5115,6 +5206,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SettingsDescriptor', declaration: 'export interface SettingsDescriptor {\n ns: SettingsNamespace;\n schema: unknown;\n value: unknown;\n revision: number;\n base?: unknown;\n user?: unknown;\n applies: SettingsApplies;\n secrets?: RedactedSecret[];\n}', }, + { + name: 'SettingsDocumentOpenValue', + declaration: 'export interface SettingsDocumentOpenValue {\n readonly opened: true;\n}', + }, { name: 'SettingsNamespace', declaration: 'export type SettingsNamespace = Branded<\'SettingsNamespace\'>;', @@ -5183,10 +5278,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SkillDefinition', declaration: 'export interface SkillDefinition extends SkillSummary {\n readonly content: string;\n readonly path?: string;\n readonly metadata?: Readonly>;\n}', }, + { + name: 'SkillEntry', + declaration: 'export interface SkillEntry {\n readonly name: string;\n readonly description: string;\n readonly whenToUse?: string;\n readonly modelInvocable: boolean;\n}', + }, { name: 'SkillInvocationPolicy', declaration: 'export interface SkillInvocationPolicy {\n readonly modelInvocable: boolean;\n readonly userInvocable: boolean;\n}', }, + { + name: 'SkillListRequest', + declaration: 'export interface SkillListRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'SkillListValue', + declaration: 'export interface SkillListValue {\n readonly skills: readonly SkillEntry[];\n}', + }, { name: 'SkillLookupOptions', declaration: 'export interface SkillLookupOptions {\n readonly cwd?: string | undefined;\n readonly signal?: AbortSignal | undefined;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 600040a06f..a339fcb16a 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: 6586cee0b7edeb9ba0db63e9a0a13afc7323d9a5 -README.zh.md: c8984783b1fc1dcceec9bfb3b247a8805319726f +README.md: 131ea9c510733740664ca8b46510650110bca234 +README.zh.md: 4578e89acc73f1a77648dce087da1bca6b364f16 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 6586cee0b7..131ea9c510 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -1,5 +1,5 @@ --- -description: "The shared API gateway for web GUI host clients: the browser-safe API contract, the fetch carriers, and the host-side gateway service every client shape uses." +description: "Legacy HTTP transport for Host bootstrap metadata and streamed Session-log ZIP downloads while generated Typert Remotes own business operations." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` — sessions and history, workspaces, directory picking, model selection, agent presets, skills, goals, settings, LLM catalogs, events, and session export — moved over HTTP or in-process by fetch carriers. The contract layer has zero Node dependencies and imports from the browser, so one typed API serves the Web server, Electron, and any future client shape. The shipped Web composition assembles the gateway in [`dsh-web-app`](../../bundle/web-app/README.md). Choosing a carrier, calling the domain APIs, and configuring the gateway come first; the wire protocol internals live in a collapsible developer section below. +`dsh-host-apiproxy` carries the two Host operations that do not yet belong to a generated business Remote: the `host.describe` bootstrap snapshot and streamed Session-log ZIP downloads. Its browser-safe envelope and fetch adapters serve HTTP and in-process clients, while API Gateway carries all ordinary business operations. The shipped Web composition assembles both transports in [`dsh-web-app`](../../bundle/web-app/README.md). ## Table of Contents @@ -25,7 +25,7 @@ Every client of the web GUI host calls one typed API through `dsh-host-apiproxy` ## Use this package -Compose the gateway when a client of the GUI host needs the session, workspace, and configuration APIs: load `ApiProxyService`, wrap `ctx.apiProxy` in a carrier, and call the typed domain methods. +Compose this package when a GUI host needs bootstrap metadata and Session-log export: load `ApiProxyService`, wrap `ctx.apiProxy` in a carrier, and use generated Remotes for all other business calls. ### Choosing a carrier @@ -33,31 +33,19 @@ Compose the gateway when a client of the GUI host needs the session, workspace, ```text const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) -const response = await client.sessions.list({}) +const response = await client.host.describe({}) ``` The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross-site simple requests can never run a side-effectful method blind. The browser carrier applies the same Host/Origin checks and signed-cookie authentication to every Host API method ([`dsh-client-connection`](../../client/connection/README.md)); individual Client features may still withhold native or persistent operations on non-loopback pages. ### What the gateway exposes -The API is grouped into domains: `sessions` (list, create, history, prompt, cancel, queue, models, selectModel, rename, fork, search, attachment), `workspace`, `host` (describe, openPath), `skills`, `agentPresets`, `goals`, `settings`, `llm`, `events`, and `downloads`. The sessions, workspace, and events contracts are owned by the Session Controller, Workspace Controller, and API Remotes packages respectively; the remaining domain contracts and the `RpcMethodMap` live in `src/api/`. - -### Sessions and history - -`session.history` pages a session's appended message stream (`maxMessages` counts append-origin `user/message` and `assistant/message` events, so model-only replacement copies consume no quota) and keeps each page a contiguous raw event range, which keeps a compaction's log-only summary on the same page as the replacement that cites it. The tail page optionally carries a `projections` block — the watermark snapshot of every registered projection unit — while the gateway pushes live `session/projection` frames for units whose state changed. `session.search` is a bounded content-search projection over the sessions visible through `session.list`: at most 20 hits, snippets of at most 240 code points, and every hit revalidated against the visible set. - -### Workspaces and the session list - -`session.list` and `workspace.list` are separate reconnect baselines. Blank sessions stay hidden until the first turn, archiving hides a session from grouping surfaces without touching its log, and registration deletion preserves the directory and session logs. Cold summaries verify blankness by probing a small eligible artifact; a projection-cache miss or stale hint falls back to `createdAt`, so a recently worked large session may sort lower until the next checkpoint. +The unary map contains only `host.describe`; the direct download route is `GET` or `HEAD /api/session.export`. Session, workspace, settings, credentials, LLM, skill, file-reference, command, and interaction operations are generated Remotes owned by their business packages and assembled by [`dsh-api-remotes`](../../api/remotes/README.md). ### Exporting sessions `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP of the session's stored artifact text verbatim, every subagent descendant under `subagents//`, and each referenced image under `media/.`. `HEAD` runs the same root preparation without a body, so browsers detect pre-stream failures before handing the GET to the download manager. The response is chunked as it is produced, and `sessionExportCompressionLevel` (0–9, default 6) trades CPU and latency against archive size. Missing persistence, session-query, or attachment services answer 500, a backend without per-session raw artifacts 501, and a missing root session 404. -### Model selection, presets, commands, and configuration - -`session.models` reports the current `ModelSelection` separately from provider-grouped advisory models, and `session.selectModel` saves an accepted switch as the deployment default through the shared `agent-default-model` settings section — a default naming an unavailable provider still reaches the selector as `current` instead of being silently replaced. Each access resolves an in-process selection first, then the session's latest `request/header`, then the deployment default. A logged reasoning effort marked as an adapter default remains absent from the restored selection, so the next resolution does not promote it into an explicit choice or record a false header change. `agentPreset.list` exposes the deployment's preset roster with each row's `trust` and a `broken` reason when a preset cannot compose a session; `agentPreset.select` swaps a blank session's composition and is refused once a turn has run. `skill.list` serves the composer menu with each skill's `modelInvocable` flag, and `command.execute` runs a slash command with pure admission semantics whose outcome rides the logged `command/run`/`command/done` pair. The remaining configuration operations are `settings.openDocument` and the `llm.*` domain; generated settings and credential methods belong to [`@deepseek-ai/dsh-api-settings-controller`](../../api/settings-controller/README.md). - ### Configuration | Field | Default | Meaning | @@ -88,19 +76,18 @@ The package is built on one separation: the API contract is channel-independent, | [`src/fetch/client.ts`](src/fetch/client.ts) | Client carrier: `AbstractApiClient` plus platform subclasses, `InProcessApiClient` | | [`src/api-proxy.ts`](src/api-proxy.ts) | Gateway implementation: `createApiProxy` over the composed host context | | [`src/session-export.ts`](src/session-export.ts) | Session-log ZIP export: raw artifact reads, media collection, fflate streaming | -| [`src/native-path-opener.ts`](src/native-path-opener.ts) | Platform opener for paths (`open`/`Invoke-Item`/`xdg-open`, WSL translation) | ### The gateway service -`ApiProxyService` provides `ctx.apiProxy` and implements the contract over the composed host context — sessions, workspace registry, directory picker, agent presets, settings, LLM, events, and downloads. The Host cwd is the default project directory. The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata `host.describe` reports; `session.selectModel` (Session Controller) saves an accepted switch as the deployment default through the shared agent-default-model settings section. Product `dsh --profile headless` is a direct core entry point and does not mount this package. +`ApiProxyService` provides `ctx.apiProxy`, reports process metadata through `host.describe`, and delegates Session archive production to the persistence, query, attachment, and live Session services. The Host cwd is the default project directory. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ### Request flow -A request enters a carrier, which parses the envelope and the business payload in two levels, dispatches per method, and returns a response echoing the request's `rpcId`. Server pushes — the session and workspace follow streams — ride the API Gateway's `/api/remote.mux` WebSocket and deliver `opened` then gap-free `event` frames the client decodes. Unary requests carry the carrier's abort signal, so caller/connection cancellation propagates to the underlying work. +A `host.describe` request enters the fetch carrier, which parses the envelope and payload, dispatches the method, and returns a response echoing the request's `rpcId`. Session export bypasses that envelope because its streamed ZIP body and HTTP status are the result. ### What the gateway owns -The gateway is the wire contract plus a host-side projection over services owned elsewhere: it emits no cordis events, and the session/agent event streams it projects are asserted by their owning packages' companions. The carrier holds no other domain's knowledge — each projection value already passed its unit's own schema inside the registry. +The package owns its legacy envelope, Host bootstrap snapshot, and archive download. API Gateway owns generated Remote dispatch and streams; business packages own their methods and result types. @@ -135,12 +122,7 @@ None; this package neither assembles nor sends a provider request. These limits define where the gateway is a poor fit; they are current package constraints, not a task backlog. -- **Forwarded Remote events ride the gateway stream framing** — the delivery path reuses the API Gateway's Remote stream mux instead of opening a third downlink, which reads as if this package owned the Remote event contract. It does not: the allowlist belongs to `dsh-api-remotes` and the consumer verb is `ctx.remote.$on` ([rationale](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md)). -- **Pending-interaction state is host-side** — the browser's pending-interaction snapshot folds plugin-registered pending domains (user questions and approvals); the wire defines no dedicated respond route and no `RpcReceipt` type. -- **Reserved seams stay out of `RpcMethodMap`** — `prompt.mode: 'inject'`, `job.list`, and a describe `hostInstanceId` are documented reservations; model discovery uses `llm.models`. An unknown method fails loud at envelope parse rather than getting a not-implemented code. - **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. -- **Search failures include provider diagnostics** — the gateway is a single-user local service; a carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic. -- **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt` unless an eligible small artifact supplies an exact fold. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md). ### Dev Note diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index c8984783b1..4578e89acc 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -1,5 +1,5 @@ --- -description: "web GUI 宿主客户端共享的 API 网关:浏览器安全的 API 约定、fetch 载体,以及每种客户端形态都使用的宿主侧网关服务。" +description: "Host 启动元数据与 Session 日志 ZIP 流下载的旧版 HTTP 载体;普通业务操作由生成的 Typert Remote 持有。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类型化 API——会话与历史、工作区、目录选择、模型选择、agent preset、skill、目标、设置、LLM 目录、事件与会话导出——由 fetch 载体经由 HTTP 或进程内搬运。约定层零 Node 依赖、可从浏览器导入,因此一套类型化 API 同时服务 Web 服务器、Electron 与任何未来的客户端形态。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装网关。选择载体、调用领域 API 与配置网关在前;协议内部细节放在下方可折叠的开发者章节中。 +`dsh-host-apiproxy` 承载尚不属于生成业务 Remote 的两项 Host 操作:`host.describe` 启动快照与流式 Session 日志 ZIP 下载。它的浏览器安全 envelope 与 fetch adapter 服务 HTTP 和进程内客户端,其余普通业务操作由 API Gateway 承载。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装两种传输。 ## 目录 @@ -25,7 +25,7 @@ web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类 ## 使用本包 -当 GUI 宿主的客户端需要会话、工作区与配置 API 时组合网关:加载 `ApiProxyService`,把 `ctx.apiProxy` 包进一个载体,然后调用类型化的领域方法。 +当 GUI Host 需要启动元数据与 Session 日志导出时组合本包:加载 `ApiProxyService`,把 `ctx.apiProxy` 包进一个载体,其他业务调用使用生成的 Remote。 ### 选择载体 @@ -33,31 +33,19 @@ web GUI 宿主的每个客户端都通过 `dsh-host-apiproxy` 调用同一套类 ```text const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) -const response = await client.sessions.list({}) +const response = await client.host.describe({}) ``` HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站「简单请求」永远无法盲目执行有副作用的方法。浏览器载体对每个 Host API 方法实施相同的 Host/Origin 检查与签名 cookie 认证([`dsh-client-connection`](../../client/connection/README.zh.md));各 Client 功能仍可以在非 loopback 页面上拒绝原生操作或持久化操作。 ### 网关暴露什么 -API 按领域分组:`sessions`(list、create、history、prompt、cancel、queue、models、selectModel、rename、fork、search、attachment)、`workspace`、`host`(describe、openPath)、`skills`、`agentPresets`、`goals`、`settings`、`llm`、`events` 与 `downloads`。sessions、workspace 与 events 契约分别归 Session Controller、Workspace Controller 与 API Remotes 包所有;其余领域契约与 `RpcMethodMap` 位于 `src/api/`。 - -### 会话与历史 - -`session.history` 对会话的追加消息流分页(`maxMessages` 统计以追加方式进入 surface 的 `user/message` 与 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额),并让每一页保持一段连续的原始事件区间,这使压缩的仅日志摘要与引用它的替换留在同一页。尾页可选携带 `projections` 块——每个已注册投影单元的水位线快照——网关会为状态发生变化的单元推送实时的 `session/projection` 帧。`session.search` 是对 `session.list` 可见会话的有界内容搜索投影:至多 20 个命中、每个摘要至多 240 个码点,且每个命中都对照可见集合重新校验。 - -### 工作区与会话列表 - -`session.list` 与 `workspace.list` 是彼此独立的重连基线。空白会话在首轮开始前保持隐藏,归档会把会话从分组表面隐藏而不触碰其日志,注销注册则保留目录与会话日志。冷摘要通过探测一个小型合格工件来验证空白状态;projection cache miss 或陈旧提示会回退到 `createdAt`,因此最近工作过的大型会话可能在下一个 checkpoint 前排得偏低。 +一元映射只包含 `host.describe`;直接下载路由是 `GET` 或 `HEAD /api/session.export`。Session、workspace、settings、credentials、LLM、skill、file-reference、command 与 interaction 操作都是由各业务包持有、并由 [`dsh-api-remotes`](../../api/remotes/README.zh.md) 组装的生成 Remote。 ### 导出会话 `GET /api/session.export?sessionId=…&includeDescendants=true` 流式输出一个 ZIP,其中每个会话的已存工件文本原样包含,每个子代理后代位于 `subagents//` 下,每张被引用的图片位于 `media/.` 下。`HEAD` 在无请求体的情况下运行同样的根准备,因此浏览器能在把 GET 交给下载管理器之前检测到流前失败。响应边生成边分块输出,`sessionExportCompressionLevel`(0–9,默认 6)在 CPU 与延迟之间权衡归档大小。缺少 persistence、session-query 或 attachment 服务时回答 500,后端没有按会话原始工件时回答 501,根会话缺失时回答 404。 -### 模型选择、preset、命令与配置 - -`session.models` 把当前 `ModelSelection` 与按提供方分组的咨询模型分开报告,`session.selectModel` 通过共享的 `agent-default-model` settings 分节把已接受的切换保存为部署默认值——指向不可用提供方的默认值仍会作为 `current` 送到选择器,而不是被静默替换。每次访问都先解析进程内选择,再读会话最新的 `request/header`,最后使用部署默认值。日志中标记为适配器默认值的推理强度不会进入恢复后的选择,因此下一次解析不会把它提升为显式选择,也不会记录虚假 header 变更。`agentPreset.list` 暴露部署的 preset 名单,每行带 `trust`,preset 无法组合会话时带 `broken` 原因;`agentPreset.select` 替换空白会话的组合,一旦跑过一轮即被拒绝。`skill.list` 为 composer 菜单提供每个 skill 的 `modelInvocable` 标志,`command.execute` 以纯准入语义运行斜杠命令,其结局由落账的 `command/run`/`command/done` 事件对承载。剩余配置操作是 `settings.openDocument` 与 `llm.*` 领域;生成的 settings 与凭据方法归 [`@deepseek-ai/dsh-api-settings-controller`](../../api/settings-controller/README.zh.md) 所有。 - ### 配置 | 字段 | 默认值 | 含义 | @@ -88,19 +76,18 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q | [`src/fetch/client.ts`](src/fetch/client.ts) | 客户端载体:`AbstractApiClient` 及平台子类、`InProcessApiClient` | | [`src/api-proxy.ts`](src/api-proxy.ts) | 网关实现:基于所组合宿主上下文的 `createApiProxy` | | [`src/session-export.ts`](src/session-export.ts) | 会话日志 ZIP 导出:原始工件读取、媒体收集、fflate 流式输出 | -| [`src/native-path-opener.ts`](src/native-path-opener.ts) | 平台路径打开器(`open`/`Invoke-Item`/`xdg-open`、WSL 转换) | ### 网关服务 -`ApiProxyService` 提供 `ctx.apiProxy`,并基于所组合的宿主上下文实现约定——会话、工作区注册表、目录选择器、agent preset、设置、LLM、事件与下载。Host cwd 是默认项目目录。网关只在 `host.describe` 报告的部署元数据中消费 `ctx.agentDefaultModel`;保存已接受的切换由 Session Controller 的 `session.selectModel` 通过共享的 agent-default-model settings 分节完成。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 +`ApiProxyService` 提供 `ctx.apiProxy`,通过 `host.describe` 报告进程元数据,并把 Session 归档生成委派给 persistence、query、attachment 与 live Session 服务。Host cwd 是默认项目目录。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ### 请求流 -请求进入载体,载体分两层解析信封与业务载荷、按方法分发,并返回回显请求 `rpcId` 的响应。服务器推送——会话与工作区 follow 流——搭乘 API Gateway 的 `/api/remote.mux` WebSocket,投递 `opened` 及之后无间隙的 `event` 帧,由客户端解码。一元请求携带载体的中止信号,因此调用方/连接的取消会传播到底层工作。 +`host.describe` 请求进入 fetch 载体,载体解析 envelope 与 payload、分发方法,并返回回显请求 `rpcId` 的响应。Session 导出不使用该 envelope,因为其流式 ZIP body 与 HTTP 状态就是结果。 ### 网关拥有什么 -网关是协议约定外加一层对别处所拥有服务的宿主侧投影:它不发出任何 cordis 事件,它所投影的会话/agent 事件流由各自所属包的伴生插件断言。载体不持有其他领域的知识——每个投影值在注册表内部已经过其单元自己的 schema。 +本包持有旧版 envelope、Host 启动快照与归档下载。API Gateway 持有生成的 Remote 分发与流;业务包持有各自的方法和结果类型。 @@ -135,12 +122,7 @@ API 按领域分组:`sessions`(list、create、history、prompt、cancel、q 这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。 -- **转发的 Remote 事件搭乘网关流帧封装**——投递路径复用 API Gateway 的 Remote 流 mux、不必新开第三条下行通道,因此读起来像是本包拥有 Remote 事件契约。并非如此:名单归 `dsh-api-remotes`,消费端动词是 `ctx.remote.$on`([原委](../../../.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md))。 -- **待处理交互状态位于宿主侧**——浏览器的待处理交互快照由插件注册的待处理领域(用户提问与审批)折叠而成;wire 未定义专门的 respond 路由,也没有 `RpcReceipt` 类型。 -- **预留 seam 不进入 `RpcMethodMap`**——`prompt.mode: 'inject'`、`job.list` 和描述字段 `hostInstanceId` 都是已记录的预留项;模型发现使用 `llm.models`。未知方法会在信封解析时直接失败,而不会返回「尚未实现」错误码。 - **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 -- **搜索失败会包含提供方诊断信息**——网关是单用户本地服务;将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。 -- **冷列表提示只向“保持可见、排序偏旧”降级**——projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,除非符合资格的小工件提供精确折叠。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.zh.md)的范围。 ### 开发备注 diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts deleted file mode 100644 index 76643472e0..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ /dev/null @@ -1,375 +0,0 @@ -/** API Proxy behavior for Agent preset management and preset-scoped catalogs. */ - -import { mkdtempSync, realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' -import { RpcId, type RpcRequest } from '../src/api/rpc.ts' -import type { ApiProxy } from '../src/api/index.ts' -import { - agentPresetProjectionDefinition, InvalidPresetIdError, PresetExistsError, UnknownPresetError, -} from '@deepseek-ai/dsh-agent-presets' -import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { createApiProxy } from '../src/api-proxy.ts' -import { describe, expect, it } from 'vitest' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' - -let nextRpc = 0 -function request

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(`preset-${String(nextRpc++)}`), payload } -} - -const sessionHarnesses = new WeakMap() - -async function createSession( - api: ApiProxy, - request: { readonly sessionId: SessionId; readonly agentPreset?: string }, -): Promise { - const harness = sessionHarnesses.get(api) - if (harness === undefined) throw new Error('Session test harness is not installed') - const presets = harness.ctx.get('agentPresets') - const agentPreset = presets === undefined - ? undefined - : (await presets.resolve(request.agentPreset)).id - await harness.ctx.agents.create({ - sessionId: request.sessionId, - meta: { - cwd: harness.cwd, - ...(agentPreset === undefined ? {} : { agentPreset }), - }, - ...(agentPreset === undefined || presets === undefined - ? {} - : { setup: async (agentCtx: Context) => { await presets.mount(agentCtx, agentPreset) } }), - }) -} - -/** Minimal live agent; the gateway only needs identity and its session. */ -function stubAgent(session: Session): Agent { - return { id: session.id, session, status: 'idle' } as unknown as Agent -} - -/** - * A roster whose `mount` is a no-op: this spec is about the gateway's identity - * rules, and the composition itself is covered by the real-composition test in - * `apps/cli`. Ids listed in `userIds` present as locally authored; the rest - * ship with the deployment. - */ -function roster(ids: readonly string[], userIds: readonly string[] = []): unknown { - const trustOf = (id: string): 'system' | 'user' => (userIds.includes(id) ? 'user' : 'system') - const presetOf = (id: string): object => - ({ id, trust: trustOf(id), path: `/presets/${id}/agent.cordis.yml` }) - return { - defaultId: ids[0], - list: () => Promise.resolve(ids.map(presetOf)), - resolve: (id?: string) => { - const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) - return Promise.resolve(presetOf(wanted)) - }, - mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), - // What a real mount leaves behind: a service instance only the agent that - // mounted it can be used to address. The doubles are per agent so a test - // can tell "this session's" from "some session's". - serviceFor: (agent: { id: unknown }, name: string) => { - const perAgent = services.get(String(agent.id)) - return perAgent?.[name] - }, - authorable: true, - read: (id: string) => Promise.resolve(`# ${id}\n- id: x\n name: y\n`), - copy: (from: string, id: string) => { - if (!ids.includes(from)) return Promise.reject(new UnknownPresetError(from, ids)) - if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) return Promise.reject(new InvalidPresetIdError(id)) - if (ids.includes(id)) return Promise.reject(new PresetExistsError(id)) - return Promise.resolve() - }, - remove: (id: string) => { - if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) - return Promise.resolve() - }, - recompose: (_ctx: Context, id: string) => { - if (!ids.includes(id)) return Promise.reject(new UnknownPresetError(id, ids)) - return Promise.resolve({ id, trust: 'system', path: `/presets/${id}.yml` }) - }, - // The standing scope key a cold transcript read resolves presenters in. - standingKeyFor: (id?: string) => { - const wanted = id ?? ids[0] ?? '' - if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) - let key = standingKeys.get(wanted) - if (key === undefined) { - key = { agentPreset: wanted } - standingKeys.set(wanted, key) - } - return Promise.resolve(key) - }, - } -} - -/** Standing keys minted by the roster double. */ -const standingKeys = new Map() - -/** Per-agent service instances a mounted preset would own, keyed by session id. */ -const services = new Map>() - -async function harness( - presets?: readonly string[], - options: { userIds?: readonly string[]; defaults?: Record } = {}, -) { - const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-preset-'))) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never) - ctx.provide('sessionQuery', { - observeSession: (sessionId: SessionId) => { - const session = ctx.sessions.get(sessionId) - if (session === undefined) { - return Promise.reject(new SessionQueryError( - `session "${sessionId}" not found`, - 'SESSION_QUERY_SESSION_NOT_FOUND', - )) - } - let preset = agentPresetProjectionDefinition.init(session.header) - for (const event of session.events) { - preset = agentPresetProjectionDefinition.apply(preset, event) - } - const events = Object.freeze([...session.events]) - const lease = (): SessionObservation => ({ - source: 'live' as const, - header: session.header, - events, - cursor: events.at(-1)?.seq ?? -1, - projections: { - asOfSeq: events.at(-1)?.seq ?? -1, - values: { agentPreset: preset }, - }, - retain: lease, - [Symbol.dispose]: () => {}, - }) - return Promise.resolve(lease()) - }, - } as never) - - const factory: AgentFactory = { - async createAgent(_ownerCtx, options) { - const session = ctx.sessions.create( - options.sessionId, - options.meta === undefined ? {} : { meta: options.meta }, - ) - const agent = stubAgent(session) - // Setup runs before publication against a context that carries the - // agent, and the agent reaches back through `agent.ctx` — the pair the - // gateway's own `installTarget` relies on. - const agentCtx = ctx.extend({ agent }) - ;(agent as { ctx?: Context }).ctx = agentCtx - await options.setup?.(agentCtx) - const unregister = ctx.agents.register(agent) - return { agent, dispose: () => { unregister(); return Promise.resolve() } } - }, - async resume() { - throw new Error('test harness has no persisted sessions') - }, - } - ctx.agents.setFactory(factory) - ctx.provide('sessionController', { - resolveAgent: (sessionId: SessionId) => { - const agent = ctx.agents.get(sessionId) - return Promise.resolve(agent === undefined - ? { - error: { - code: 'session-not-found', - message: `session "${sessionId}" not found`, - details: { sessionId }, - }, - } - : { agent }) - }, - inspect: (sessionId: SessionId) => { - const session = ctx.sessions.get(sessionId) - if (session === undefined) throw new Error(`session "${sessionId}" not found`) - return Promise.resolve({ meta: session.header, events: [...session.events] }) - }, - } as never) - const defaults = { - defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), - cwd, - ...options.defaults, - } - const api = createApiProxy(ctx, defaults) - sessionHarnesses.set(api, { ctx, cwd }) - return { api, ctx, cwd } -} - -/** - * A capability a preset mounts is reachable from nowhere the host normally - * looks: an `isolate` realm is what makes it per session. The gateway serves - * requests that are ABOUT a session from OUTSIDE it, so it addresses the - * instance through the agent instead of reading a root-realm singleton. - */ -describe('a capability the session\'s preset mounts', () => { - it('serves the skill catalog from the session\'s own registry', async () => { - const { api } = await harness(['standard']) - await createSession(api, { sessionId: SessionId('k1'), agentPreset: 'standard' }) - services.set('k1', { - skills: { - list: () => Promise.resolve([{ - name: 'preset-owned', - description: 'ships inside the preset directory', - invocation: { modelInvocable: true, userInvocable: true }, - }]), - }, - }) - - const response = await api.skills.list(request({ sessionId: SessionId('k1') })) - - // A preset ships its own skill directory, so the catalog IS the - // session's; reading a host singleton would answer for the wrong one. - expect(response.result).toMatchObject({ ok: true, value: { skills: [{ name: 'preset-owned' }] } }) - services.delete('k1') - }) - - it('says so when no composition mounts the capability at all', async () => { - const { api } = await harness(['standard']) - await createSession(api, { sessionId: SessionId('n1'), agentPreset: 'standard' }) - - const response = await api.skills.list(request({ sessionId: SessionId('n1') })) - - // Absent means absent — not "this session has none", which is what a - // root-realm read used to report for every presetd session. - expect(response.result.ok).toBe(false) - const failure = response.result as { ok: false; error: { message: string } } - expect(failure.error.message).toContain('neither this session') - }) -}) - -describe('opening a preset directory', () => { - it('hands the resolved directory to the native opener', async () => { - const opened: string[] = [] - const { api } = await harness(['standard', 'my-preset'], { - userIds: ['my-preset'], - defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, - }) - - const response = await api.agentPresets.openDocument( - request({ agentPreset: 'my-preset' }), new AbortController().signal) - - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value).toEqual({ opened: true }) - // The id selected the directory; the browser supplied no path. - expect(opened).toEqual(['/presets/my-preset']) - }) - - it('answers the path as text where the deployment has no opener', async () => { - const { api } = await harness(['standard', 'my-preset'], { - userIds: ['my-preset'], - defaults: { canOpenPath: () => false }, - }) - - const response = await api.agentPresets.openDocument( - request({ agentPreset: 'my-preset' }), new AbortController().signal) - - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - expect(response.result.value).toEqual({ opened: false, path: '/presets/my-preset' }) - }) - - it('refuses a preset that ships with the deployment', async () => { - const opened: string[] = [] - const { api } = await harness(['standard'], { - defaults: { openPath: (path: string) => { opened.push(path); return Promise.resolve() } }, - }) - - const response = await api.agentPresets.openDocument( - request({ agentPreset: 'standard' }), new AbortController().signal) - - // Pointing an editor into the install invites edits an upgrade will - // silently overwrite; the refusal mirrors copy/remove. - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - expect(response.result.error.code).toBe('agent-preset-read-only') - expect(opened).toEqual([]) - }) - - it('reports the opener capability on host.describe', async () => { - const openable = await harness(['standard'], { - defaults: { canOpenPath: () => true }, - }) - const headless = await harness(['standard'], { - defaults: { canOpenPath: () => false }, - }) - - // The capability a surface joins onto the roster to decide between opening - // a preset directory and showing its path as text. - const yes = await openable.api.host.describe(request({})) - const no = await headless.api.host.describe(request({})) - - expect(yes.result.ok && yes.result.value.canOpenPath).toBe(true) - expect(no.result.ok && no.result.value.canOpenPath).toBe(false) - }) - - it('counts an injected opener as openable', async () => { - const { api } = await harness(['standard'], { - defaults: { openPath: () => Promise.resolve() }, - }) - - const response = await api.host.describe(request({})) - - expect(response.result.ok && response.result.value.canOpenPath).toBe(true) - }) -}) - -describe('skills over the layered host registry', () => { - it('passes the live agent as the view scope to the host registry', async () => { - const { api, ctx } = await harness(['standard']) - const seen: unknown[] = [] - ctx.provide('skills', { - list: (options: { scope?: unknown }) => { - seen.push(options.scope) - return Promise.resolve([]) - }, - } as never) - await createSession(api, { sessionId: SessionId('h1'), agentPreset: 'standard' }) - - const response = await api.skills.list(request({ sessionId: SessionId('h1') })) - - expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([ctx.agents.get(SessionId('h1'))]) - }) - - it('resolves a cold session to its recorded preset standing key', async () => { - const { api, ctx } = await harness(['standard', 'minimal']) - const seen: unknown[] = [] - ctx.provide('skills', { - list: (options: { scope?: unknown }) => { - seen.push(options.scope) - return Promise.resolve([]) - }, - } as never) - ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } }) - - const response = await api.skills.list(request({ sessionId: SessionId('h2') })) - - expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([standingKeys.get('minimal')]) - }) - - it('serves the global view when the roster no longer supplies the recorded preset', async () => { - const { api, ctx } = await harness(['standard']) - const seen: unknown[] = [] - ctx.provide('skills', { - list: (options: { scope?: unknown }) => { - seen.push(options.scope) - return Promise.resolve([]) - }, - } as never) - ctx.sessions.create(SessionId('h3'), { meta: { cwd: '/workspace/cold', agentPreset: 'gone' } }) - - const response = await api.skills.list(request({ sessionId: SessionId('h3') })) - - expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([undefined]) - }) -}) diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index 8152fa262e..f6ed7916b2 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -1,18 +1,14 @@ /** - * Settings and llm RPC domains and their owner events over createApiProxy: - * layered redacted describe, write-path rejection mapping, the - * directory/live-route merge, and the settings and model invalidation frames. + * Settings events consumed by Client model and permission surfaces. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' import { CredentialProvider } from '@deepseek-ai/dsh-credentials' @@ -25,29 +21,7 @@ import type { CredentialRef, ResolvedCredential, } from '@deepseek-ai/dsh-credentials' -import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' -import { RpcId } from '../src/api/rpc.ts' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' -import { createApiProxy } from '../src/api-proxy.ts' - -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } - -let nextRpc = 1 -function request

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } -} - -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; details: unknown } { - expect(response.result.ok).toBe(false) - if (response.result.ok) throw new Error('unreachable') - return response.result.error -} /** In-memory settings provider: the Service Definition base class owns all tested behavior. */ class MemorySettings extends SettingsProvider { @@ -159,32 +133,6 @@ class MemoryCredentials extends CredentialProvider { } } -/** Catalog-serving adapter stub for the llm.models path. */ -class CatalogAdapter extends LlmAdapter { - constructor(private readonly name: string, private readonly models: readonly string[]) { - super() - } - - override providerInfo(provider: string): LlmProviderInfo { - return { id: provider, name: this.name } - } - - override listModels(provider: string): Promise { - return Promise.resolve(this.models.map(id => ({ provider, id, name: id }))) - } - - - async * stream(_options: GenerateOptions): AsyncIterable { - throw new Error('not exercised') - } -} - -class BrokenCatalogAdapter extends CatalogAdapter { - override listModels(): Promise { - return Promise.reject(new Error('catalog backend down')) - } -} - const NS = settingsNamespace('llm-deepseek') const AdapterConfig = z.object({ @@ -201,24 +149,14 @@ async function harness(options?: { preparedPath?: string } credentials?: false | { shadowed?: string[] } - /** Skip the directory registration to exercise a namespace the proxy does not expose. */ - configurableProviders?: false }): Promise { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) - await ctx.plugin(LlmRuntime) if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - // Model-provider namespaces plus the explicit Web preference and product - // onboarding allowlists are the proxy's complete settings surface. - if (options?.configurableProviders !== false) { - ctx.llm.registerConfigurableProviders([ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, - ]) - } return ctx } @@ -239,93 +177,12 @@ async function captureSettingsUpdates( } } -/** Count model-adapter topology commits while one API operation runs. */ -async function countAdapterUpdates(ctx: Context, run: () => Promise): Promise { - let updates = 0 - const dispose = ctx.on('llm/adapters-updated', () => { updates += 1 }) - try { - await run() - return updates - } finally { - dispose() - } -} - /** Expected settings event tuple with its owner-assigned revision. */ function expectedSettingsUpdate(ns: string): readonly unknown[] { return [ns, expect.any(Number)] } -describe('settings domain', () => { - it('reports an actionable error when no settings provider is mounted', async () => { - const ctx = await harness({ settings: false }) - const api = createApiProxy(ctx, DEFAULTS) - const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) - expect(error.code).toBe('internal') - expect(error.message).toContain('dsh-settings-file') - }) - - - it('opens the provider-resolved document without accepting a browser path', async () => { - const ctx = await harness({ settings: { - documentPath: '/tmp/described-settings.yaml', - preparedPath: '/tmp/custom-settings.yaml', - } }) - const opened: string[] = [] - const api = createApiProxy(ctx, { - ...DEFAULTS, - openTextFile: (path) => { - opened.push(path) - return Promise.resolve() - }, - }) - - expect(expectOk(await api.settings.openDocument(request({}), new AbortController().signal))) - .toEqual({ opened: true }) - expect(opened).toEqual(['/tmp/custom-settings.yaml']) - }) - - it('refuses to open settings when the provider has no local document', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - expect(ctx.settings.documentPath).toBeUndefined() - const error = expectErr(await api.settings.openDocument(request({}), new AbortController().signal)) - expect(error.code).toBe('internal') - expect(error.message).toContain('no local document') - }) - - it('does not prepare or open a settings document after cancellation', async () => { - const ctx = await harness({ settings: { documentPath: '/tmp/settings.yaml' } }) - const opened: string[] = [] - const api = createApiProxy(ctx, { - ...DEFAULTS, - openTextFile: (path) => { - opened.push(path) - return Promise.resolve() - }, - }) - const prepare = vi.spyOn(ctx.settings, 'prepareDocument') - const cancelled = new AbortController() - cancelled.abort() - expect(expectErr(await api.settings.openDocument(request({}), cancelled.signal)).code) - .toBe('cancelled') - expect(prepare).not.toHaveBeenCalled() - - const pending = Promise.withResolvers() - prepare.mockReturnValueOnce(pending.promise) - const duringPrepare = new AbortController() - const opening = api.settings.openDocument(request({}), duringPrepare.signal) - await vi.waitFor(() => { expect(prepare).toHaveBeenCalledOnce() }) - duringPrepare.abort() - pending.resolve('/tmp/settings.yaml') - expect(expectErr(await opening).code).toBe('cancelled') - expect(opened).toEqual([]) - }) - - - - - +describe('settings events', () => { it('forwards a provider settings change for model-catalog consumers', async () => { // Editing `models` changes no route, so llm/adapters-updated never fires // and an open model picker would keep serving the stale catalog. Storing @@ -376,161 +233,4 @@ describe('settings domain', () => { -}) - -describe('llm domain', () => { - it('merges the configurable directory with live routes and appends undeclared ones', async () => { - const ctx = await harness({ configurableProviders: false }) - ctx.llm.registerConfigurableProviders([ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, - ]) - ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash'])) - ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1'])) - // Only one namespace can answer an interrogation, so the flag follows the - // entry's namespace rather than being assumed for every row. - ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([])) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.llm.providers(request({}))) - expect(value.providers).toEqual([ - { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true }, - { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false }, - // An undeclared live route has no settings address, so nothing can be - // interrogated on its behalf either. - { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true }, - ]) - }) - - it('serves the host-scoped catalog with per-provider failures contained', async () => { - const ctx = await harness() - ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash', 'deepseek-v4-pro'])) - ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', [])) - const api = createApiProxy(ctx, DEFAULTS) - const value = expectOk(await api.llm.models(request({}))) - expect(value.default).toEqual({ provider: 'p', model: 'm' }) - expect(value.routableProviders).toEqual(['deepseek-official', 'broken']) - expect(value.groups).toEqual([{ - id: 'deepseek-official', - name: 'DeepSeek', - models: [ - { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, - { id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, - ], - }]) - expect(value.failures).toEqual([{ id: 'broken', name: 'Broken', message: 'catalog backend down' }]) - }) - - it('forwards llm/adapters-updated at every topology commit point', async () => { - const ctx = await harness() - const updates = await countAdapterUpdates(ctx, async () => { - const dispose = ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [])) - dispose() - return Promise.resolve() - }) - expect(updates).toBe(2) - }) -}) - -describe('llm.discoverModels', () => { - it('carries a draft to its namespace and returns candidates without storing anything', async () => { - const ctx = await harness() - const seen: unknown[] = [] - ctx.llm.registerModelDiscovery('llm-pi-ai', (probe) => { - seen.push({ baseURL: probe.baseURL, api: probe.api, apiKey: probe.apiKey }) - return Promise.resolve([ - { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, - { id: 'acme-small' }, - ]) - }) - const api = createApiProxy(ctx, DEFAULTS) - - const value = expectOk(await api.llm.discoverModels(request({ - settingsNs: 'llm-pi-ai', - baseURL: 'https://gateway.acme.example/v1', - api: 'openai-completions', - apiKey: 'probe-key', - }))) - - expect(value.models).toEqual([ - { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, - { id: 'acme-small' }, - ]) - expect(seen).toEqual([{ - baseURL: 'https://gateway.acme.example/v1', - api: 'openai-completions', - apiKey: 'probe-key', - }]) - // Interrogating a draft is a read: no namespace gained a section, and no - // credential reference was written. - expect(ctx.settings.describe().map(view => String(view.ns))).not.toContain('llm-pi-ai') - }) - - it('carries the route being edited so an adapter can answer from its own registry', async () => { - const ctx = await harness() - let probe: unknown - ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => { - probe = request_ - return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }]) - }) - const api = createApiProxy(ctx, DEFAULTS) - - const value = expectOk(await api.llm.discoverModels(request({ - settingsNs: 'llm-pi-ai', - provider: 'deepseek', - }))) - - // No endpoint at all: a route the adapter already describes needs none. - expect(probe).toEqual({ provider: 'deepseek' }) - expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }]) - }) - - it('omits a credential and protocol the draft does not name', async () => { - const ctx = await harness() - let probe: unknown - ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => { - probe = request_ - return Promise.resolve([]) - }) - const api = createApiProxy(ctx, DEFAULTS) - - expectOk(await api.llm.discoverModels(request({ - settingsNs: 'llm-pi-ai', - baseURL: 'https://gateway.acme.example/v1', - }))) - - // Absent fields stay absent rather than crossing as explicit undefined: - // the adapter distinguishes "no protocol named" from "protocol undefined". - expect(probe).toEqual({ baseURL: 'https://gateway.acme.example/v1' }) - }) - - it('reports a failed interrogation as the form\'s next move, naming no credential', async () => { - const ctx = await harness() - ctx.llm.registerModelDiscovery('llm-pi-ai', () => - Promise.reject(new Error('https://gateway.acme.example/v1/models answered 401; check the API key'))) - const api = createApiProxy(ctx, DEFAULTS) - - const error = expectErr(await api.llm.discoverModels(request({ - settingsNs: 'llm-pi-ai', - baseURL: 'https://gateway.acme.example/v1', - apiKey: 'wrong', - }))) - - expect(error.code).toBe('model-discovery-failed') - expect(error.message).toContain('answered 401; check the API key') - expect(error.details).toEqual({ settingsNs: 'llm-pi-ai', baseURL: 'https://gateway.acme.example/v1' }) - expect(JSON.stringify(error)).not.toContain('wrong') - }) - - it('reports a namespace no adapter family serves', async () => { - const ctx = await harness() - const api = createApiProxy(ctx, DEFAULTS) - - const error = expectErr(await api.llm.discoverModels(request({ - settingsNs: 'llm-deepseek', - baseURL: 'https://api.deepseek.com', - }))) - - expect(error.code).toBe('model-discovery-failed') - expect(error.message).toContain('no model discovery is registered') - }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts index 681f2d0ed4..180be46507 100644 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-host.spec.ts @@ -25,7 +25,6 @@ function expectOk(response: { readonly result: { readonly ok: true; readonly async function harness( extras: { - openPath?: (path: string, signal: AbortSignal) => Promise canOpenPath?: () => boolean } = {}, ) { @@ -35,13 +34,12 @@ async function harness( const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd: '/tmp/dsh-apiproxy-host', - ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath }, }) return { api } } -describe('host.openPath', () => { +describe('host.describe', () => { it('describes whether the deployment can reach a native desktop', async () => { const visible = await harness({ canOpenPath: () => true }) const headless = await harness({ canOpenPath: () => false }) @@ -50,27 +48,4 @@ describe('host.openPath', () => { expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) }) - it('opens through the injected native boundary', async () => { - const opened: string[] = [] - const { api } = await harness({ - openPath: async (path) => { opened.push(path) }, - }) - expect((await api.host.openPath( - request({ path: '/tmp/a.txt' }), - new AbortController().signal, - )).result).toEqual({ ok: true, value: { opened: true } }) - expect(opened).toEqual(['/tmp/a.txt']) - }) - - it('propagates abort into the native boundary as a cancelled RPC error', async () => { - const { api } = await harness({ - openPath: (_path, signal) => new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) - }), - }) - const abort = new AbortController() - const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal) - abort.abort() - expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } }) - }) }) diff --git a/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts deleted file mode 100644 index 9883179ea0..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' -import type {} from '@deepseek-ai/dsh-skill' -import { describe, expect, it, vi } from 'vitest' -import { createApiProxy } from '../src/api-proxy.ts' -import { RpcId } from '../src/api/rpc.ts' - -describe('skill catalog Session inspection', () => { - it('reads a detached Session without resuming its Agent', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - const sessionId = SessionId('cold-skills') - const resolveAgent = vi.fn() - const dispose = vi.fn() - const observeSession = vi.fn(() => Promise.resolve({ - source: 'live', - header: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' }, - events: [], - cursor: -1, - projections: { asOfSeq: -1, values: {} }, - retain: () => { throw new Error('not retained') }, - [Symbol.dispose]: dispose, - } satisfies SessionObservation)) - ctx.provide('sessionQuery', { observeSession } as never) - ctx.provide('sessionController', { resolveAgent } as never) - const list = vi.fn(() => Promise.resolve([{ - name: 'review', - description: 'Review the current change.', - invocation: { modelInvocable: true, userInvocable: true }, - }])) - ctx.provide('skills', { list } as never) - const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/default', - }) - - const response = await api.skills.list({ rpcId: RpcId('cold-skills'), payload: { sessionId } }) - - expect(response.result).toEqual({ - ok: true, - value: { - skills: [{ - name: 'review', - description: 'Review the current change.', - modelInvocable: true, - }], - }, - }) - expect(observeSession).toHaveBeenCalledWith(sessionId) - expect(dispose).toHaveBeenCalledOnce() - expect(resolveAgent).not.toHaveBeenCalled() - expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined }) - }) - - it('preserves missing and failed cold inspection as distinct API errors', async () => { - const sessionId = SessionId('missing-skills') - for (const fixture of [ - { - error: new SessionQueryError( - 'session "missing-skills" not found', - 'SESSION_QUERY_SESSION_NOT_FOUND', - ), - code: 'session-not-found', - }, - { error: new Error('storage offline'), code: 'internal' }, - ] as const) { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - ctx.provide('sessionQuery', { - observeSession: () => Promise.reject(fixture.error), - } as never) - ctx.provide('skills', { list: vi.fn() } as never) - const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/default', - }) - - const response = await api.skills.list({ rpcId: RpcId(fixture.code), payload: { sessionId } }) - - expect(response.result).toMatchObject({ ok: false, error: { code: fixture.code } }) - } - }) -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index b570fbeec7..e587346061 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -16,41 +16,14 @@ function ok(request: RpcRequest, value: T): Promise> /** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ function scriptedApi(overrides: { host?: Partial - skills?: Partial - agentPresets?: Partial - settings?: Partial - llm?: Partial } = {}): ApiProxy { - const err = (r: RpcRequest): Promise> => - Promise.resolve({ rpcId: r.rpcId, result: { ok: false, error: { code: 'internal' as const, message: 'stub', details: {} } } }) return { host: { describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, }), - openPath: r => ok(r, { opened: true as const }), ...overrides.host, }, - skills: { list: r => ok(r, { skills: [] }), ...overrides.skills }, - agentPresets: { - openDocument: r => ok(r, { opened: true as const }), - ...overrides.agentPresets, - }, - settings: { - openDocument: r => ok(r, { opened: true as const }), - ...overrides.settings, - }, - llm: { - providers: r => ok(r, { providers: [] }), - models: r => ok(r, { - default: { provider: 'test', model: 'test' }, - routableProviders: [], - groups: [], - failures: [], - }), - discoverModels: err, - ...overrides.llm, - }, downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } @@ -59,15 +32,6 @@ function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient { return new InProcessApiClient(toFetchHandler(api), timeoutMs) } -/** Wrap one scripted method to record its invocation into `seen` before responding. */ -function recorderInto(seen: { method: string; payload: unknown }[]) { - return (method: string, respond: (r: RpcRequest

    ) => Promise>) => - (r: RpcRequest

    ): Promise> => { - seen.push({ method, payload: r.payload }) - return respond(r) - } -} - describe('unary round trip', () => { it('carries payload out and value back through the full wire form', async () => { let seen: RpcRequest<{}> | undefined @@ -86,11 +50,6 @@ describe('unary round trip', () => { expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } }) }) - it('routes the agent-preset document opener through the wire', async () => { - const opened = await client(scriptedApi()).agentPresets.openDocument({ agentPreset: 'mine' }) - expect(opened.result).toEqual({ ok: true, value: { opened: true } }) - }) - it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ host: { @@ -116,17 +75,6 @@ describe('unary round trip', () => { await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/) }) - it('rejects a method/path mismatch as bad-request', async () => { - const handler = toFetchHandler(scriptedApi()) - const body = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } - const response = await handler.fetch('http://dsh.internal/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) - expect(response.status).toBe(200) - const parsed = await response.json() as { result: { ok: boolean; error?: { code: string; message: string } } } - expect(parsed.result.ok).toBe(false) - expect(parsed.result.error?.code).toBe('bad-request') - expect(parsed.result.error?.message).toMatch(/does not match path/) - }) - it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { const handler = toFetchHandler(scriptedApi()) // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. @@ -278,68 +226,3 @@ describe('envelope tap', () => { expect(batches).toEqual([]) }) }) - -describe('config unary surface', () => { - it('round-trips every settings/llm method with its own payload and value shape', async () => { - const seen: { method: string; payload: unknown }[] = [] - const record = recorderInto(seen) - const providerRow = { - provider: 'openai', - displayName: 'openai', - settingsNs: 'llm-pi-ai', - settingsPath: ['providers', 'openai'], - active: false, - } - const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] } - const api = scriptedApi({ - settings: { - openDocument: record('settings.openDocument', r => ok(r, { opened: true as const })), - }, - llm: { - providers: record('llm.providers', r => ok(r, { providers: [providerRow] })), - models: record('llm.models', r => ok(r, { - default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routableProviders: ['deepseek-official'], - groups: [group], - failures: [], - })), - discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })), - }, - }) - const c = client(api) - - expect((await c.settings.openDocument({})).result).toEqual({ ok: true, value: { opened: true } }) - const providers = await c.llm.providers({}) - expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } }) - const models = await c.llm.models({}) - expect(models.result).toEqual({ - ok: true, - value: { - default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - routableProviders: ['deepseek-official'], - groups: [group], - failures: [], - }, - }) - const discovered = await c.llm.discoverModels({ - settingsNs: 'llm-pi-ai', - baseURL: 'https://gateway.acme.example/v1', - api: 'openai-completions', - apiKey: 'probe-key', - }) - expect(discovered.result).toEqual({ ok: true, value: { models: [{ id: 'acme-large', contextWindow: 65536 }] } }) - - expect(seen.map(call => call.method)).toEqual([ - 'settings.openDocument', - 'llm.providers', 'llm.models', 'llm.discoverModels', - ]) - // The draft crosses whole, credential included: the host needs it for this - // one interrogation and stores none of it. - expect(seen[3]?.payload).toEqual({ - settingsNs: 'llm-pi-ai', - baseURL: 'https://gateway.acme.example/v1', - api: 'openai-completions', - apiKey: 'probe-key', - }) - }) -}) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 284a973cb4..2cf89e675d 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { ApiProxy } from '../src/api/index.ts' -import type { RpcMessage, RpcRequest } from '../src/api/rpc.ts' +import type { RpcMessage } from '../src/api/rpc.ts' import { toFetchHandler } from '../src/fetch/handler.ts' import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' @@ -18,46 +18,6 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { }, } }, - async openPath(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } - }, - }, - agentPresets: { - openDocument(request: RpcRequest<{ agentPreset: string }>) { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: true as const, value: { opened: true as const } } }) - }, - }, - skills: { - async list(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } } } - }, - }, - settings: { - async openDocument(request) { - return { rpcId: request.rpcId, result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } } } - }, - }, - llm: { - async providers(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } } - }, - async models(request) { - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { - default: { provider: 'test', model: 'test' }, - routableProviders: [], - groups: [], - failures: [], - }, - }, - } - }, - async discoverModels(request) { - return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } } - }, }, downloads: { async sessionLog() { @@ -79,85 +39,16 @@ describe('unary round trip (handler ⇄ client, no network)', () => { }) it('carries a business error as 200 + error result', async () => { - const response = await client().settings.openDocument({}) + const api = fakeApi() + api.host.describe = request => Promise.resolve({ + rpcId: request.rpcId, + result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } }, + }) + const response = await client(api).host.describe({}) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('internal') }) - it('round-trips the agent-preset document opener', async () => { - // The opener is the domain's whole carried surface: its request schema is - // registered in both halves, so a missing registration fails here rather - // than in the browser. - expect((await client().agentPresets.openDocument({ agentPreset: 'mine' })).result) - .toEqual({ ok: true, value: { opened: true } }) - }) - - it('round-trips host.openPath through the wire form', async () => { - const api = fakeApi() - let opened: string | undefined - api.host.openPath = async (request) => { - opened = request.payload.path - return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } } - } - const response = await client(api).host.openPath({ path: '/tmp/a.txt' }) - expect(opened).toBe('/tmp/a.txt') - expect(response.result).toEqual({ ok: true, value: { opened: true } }) - }) - - it('round-trips skill.list through the wire form', async () => { - const c = client() - 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('keeps caller and connection aborts on a signal-taking unary', async () => { - const api = fakeApi() - const started = Promise.withResolvers() - api.host.openPath = async (request, signal) => { - started.resolve(signal) - 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: {} } }, - } - } - const controller = new AbortController() - const execution = client(api).host.openPath({ path: '/tmp/a.txt' }, controller.signal) - const handlerSignal = await started.promise - - controller.abort(new Error('connection closed')) - - await expect(execution).rejects.toThrow('connection closed') - expect(handlerSignal.aborted).toBe(true) - }) - - it('propagates the carrier Request signal into host.openPath', async () => { - const api = fakeApi() - api.host.openPath = async (request, signal) => { - 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: {} } }, - } - } - const handler = toFetchHandler(api) - const controller = new AbortController() - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-opener', method: 'host.openPath', payload: { path: '/tmp/a.txt' } }) - const pending = handler.fetch(new Request('http://x/api/host.openPath', { - method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal, - })) - controller.abort() - const parsed = await (await pending).json() as { result: { error?: { code: string } } } - expect(parsed.result.error?.code).toBe('cancelled') - }) }) describe('handler carrier-layer statuses', () => { @@ -182,17 +73,9 @@ describe('handler carrier-layer statuses', () => { expect(body.result.error?.code).toBe('bad-request') }) - it('rejects a method/path mismatch echoing the envelope rpcId', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-9', method: 'host.describe', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/skill.list', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - const parsed = await response.json() as { rpcId: string; result: { error?: { message: string } } } - expect(parsed.rpcId).toBe('r-9') - expect(parsed.result.error?.message).toContain('does not match path') - }) - it('rejects an invalid payload with the zod issues attached', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.openPath', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/host.openPath', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.describe', payload: null }) + const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } expect(parsed.result.error?.code).toBe('bad-request') expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) @@ -263,7 +146,7 @@ describe('envelope observation', () => { const c = client() const batches: (readonly RpcMessage[])[] = [] c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await Promise.all([c.host.describe({}), c.skills.list({ sessionId: 's1' as never })]) + await Promise.all([c.host.describe({}), c.host.describe({})]) await new Promise((resolve) => { setTimeout(resolve, 0) }) const total = batches.reduce((n, batch) => n + batch.length, 0) expect(total).toBe(4) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 3f863b7817..33cf4b2eca 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -6,8 +6,6 @@ import { } from '../src/api/rpc.schema.ts' import { z } from 'zod' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' -import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts' -import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts' describe('RpcId', () => { it('brands a raw string at zero runtime cost', () => { @@ -37,7 +35,6 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'model-discovery-failed', message: 'm', details: { settingsNs: 'n' } }).code).toBe('model-discovery-failed') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -97,32 +94,3 @@ describe('host domain schemas', () => { })).toThrow() }) }) - -describe('skills domain schemas', () => { - it('validates the list request/value pair', () => { - expect(skillListRequestSchema.parse({ sessionId: 's1' })).toEqual({ sessionId: 's1' }) - // The wire is session-addressed only: a sessionId-less payload fails. - expect(() => skillListRequestSchema.parse({})).toThrow() - expect(skillListValueSchema.parse({ skills: [] }).skills).toEqual([]) - const value = skillListValueSchema.parse({ skills: [ - { name: 'commit-helper', description: 'Git commits', whenToUse: 'when committing', modelInvocable: true }, - { name: 'bare', description: 'No guidance', modelInvocable: false }, - ] }) - expect(value.skills[0]?.whenToUse).toBe('when committing') - expect(value.skills[1]?.whenToUse).toBeUndefined() - expect(value.skills[1]?.modelInvocable).toBe(false) - expect(() => skillEntrySchema.parse({ name: '', description: 'd', modelInvocable: true })).toThrow() - // modelInvocable is required wire data: an entry without it fails. - expect(() => skillEntrySchema.parse({ name: 'n', description: 'd' })).toThrow() - }) -}) - -describe('agent-preset schemas', () => { - it('answers the open-document union by its discriminant', () => { - expect(agentPresetOpenDocumentValueSchema.parse({ opened: true })).toEqual({ opened: true }) - expect(agentPresetOpenDocumentValueSchema.parse({ opened: false, path: '/presets/mine' })) - .toEqual({ opened: false, path: '/presets/mine' }) - // A closed reply must carry the path the surface shows instead. - expect(() => agentPresetOpenDocumentValueSchema.parse({ opened: false })).toThrow() - }) -}) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 18793565d7..17d59a3712 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -293,8 +293,7 @@ describe('draft-provider model discovery', () => { }) const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'https://slow.example/v1', - signal: controller.signal, - }) + }, controller.signal) await bodyRead.promise controller.abort('test cancellation') @@ -306,8 +305,7 @@ describe('draft-provider model discovery', () => { const aborted = AbortSignal.abort('test cancellation') await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'http://127.0.0.1:9/v1', - signal: aborted, - })).rejects.toMatchObject({ code: 'ABORTED' }) + }, aborted)).rejects.toMatchObject({ code: 'ABORTED' }) }) it('is offered for the namespace, and refuses one it does not serve', async () => { diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index ffcd07c65a..65d2edff25 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/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/util/native-command/README.md -README.md: b2d5645b35f6ea157cb3d526326f60352a421550 -README.zh.md: 93299c6a0550c087f673e2c4bfe5009732168759 +README.md: 282cefcc00d296b3c09741e64c88647343e1bef2 +README.zh.md: aecb60d2e8f68a6e9468e3f2d4b0e2d99bebefc6 diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index b2d5645b35..282cefcc00 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -1,5 +1,5 @@ --- -description: "A zero-dependency no-shell execFile runner for host-native OS integrations, with utf8 stdio capture, abort propagation, and a hidden console window on Windows." +description: "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL path handoff." kind: "package-library" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-native-command` runs a host executable directly — never through a shell string — and captures its utf8 stdout and stderr. The caller's abort signal terminates the child, and on Windows the transient console window stays hidden. A failed run rejects with the exit code and both captured streams attached, so callers classify a missing tool, a cancellation, or a real failure without re-running anything. The host-side consumers are the native directory chooser and the open-with-default-application hand-off. It is a library, not a plugin: no `ctx`, no state, no events. +`dsh-native-command` runs host executables without a shell and opens Host filesystem paths through the desktop. The command runner captures utf8 output, propagates cancellation, and hides transient Windows consoles. The path opener supports default-application and text-editor intents, browser-renderable documents, WSL translation, and desktop availability checks. It is a library, not a plugin: no `ctx`, no state, no events. ## Table of Contents @@ -43,6 +43,10 @@ On exit 0 the call resolves with captured stdout and stderr. On any failure it r The `NativeCommandRunner` type is the injectable command boundary for host integrations: pass the function (or a wrapper) where the integration needs a testable seam, so tests can substitute a fake runner. +### Opening a Host path + +`openNativePath(path, signal)` hands a path to the default application and prefers the named default browser for HTML and SVG where the platform can identify one. `openNativeTextFile(path, signal)` selects text-editor intent; on macOS it uses `open -t`. WSL paths are translated with `wslpath -w` before the Windows desktop receives them. `canOpenNativePath()` reports whether the current Host plausibly has a desktop target. + ----- @@ -51,13 +55,15 @@ The `NativeCommandRunner` type is the injectable command boundary for host integ

    Implementation internals — click to expand -The runner is a thin wrapper over Node's `execFile` with three fixed choices: utf8 encoding, abort propagation, and Windows console hiding. +The command runner is a thin wrapper over Node's `execFile`. The path opener selects one shell-free command from platform and environment facts, while callers retain authority over which path may be opened. ### Source map | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `runNativeCommand` and the `NativeCommandRunner` type — the whole package | +| [`src/index.ts`](src/index.ts) | Public command-runner and path-opener exports | +| [`src/runner.ts`](src/runner.ts) | Shell-free `execFile` adapter | +| [`src/path-opener.ts`](src/path-opener.ts) | Desktop detection, open intents, browser preference, and WSL translation | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; each run is one stateless child-process round trip) | ### What execFile gives the runner @@ -74,7 +80,8 @@ The runner is a thin wrapper over Node's `execFile` with three fixed choices: ut Read these pages when you need the consumers or the general subprocess capability this utility deliberately is not. - [Native directory picker](../../host/directory-picker-native/README.md) — the OS chooser commands this runner executes. -- [Host API proxy](../../host/apiproxy/README.md) — the open-with-default-application hand-off this runner serves. +- [Session Controller](../../api/session-controller/README.md) — resolves Session-relative workspace paths before opening them. +- [Settings Controller](../../api/settings-controller/README.md) — selects settings documents and agent-preset directories. - [Subprocess capability](../../subprocess/subprocess/README.md) — the general subprocess seam, of which this package is not a part. ----- @@ -82,7 +89,7 @@ Read these pages when you need the consumers or the general subprocess capabilit ## Model Experience -None, as the host-side subprocess runner registers nothing model-facing. +None, as the host-side utilities register nothing model-facing. #### KV Cache effect diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index 93299c6a05..aecb60d2e8 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -1,5 +1,5 @@ --- -description: "供宿主原生 OS 集成使用的零依赖免 shell execFile 运行器,支持 utf8 标准流捕获、中止传播与 Windows 隐藏控制台窗口。" +description: "宿主原生命令与路径打开工具,提供无 shell 执行、取消、桌面探测与 WSL 路径交接。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-native-command` 直接运行宿主可执行文件——绝不拼 shell 字符串——并捕获其 utf8 stdout 与 stderr。调用方的中止信号会终止子进程,在 Windows 上瞬时控制台窗口保持隐藏。失败时调用会以错误拒绝,该错误附带退出码与两路已捕获输出,因此调用方无需重跑即可区分工具缺失、取消与真实失败。宿主侧消费方是原生目录选择器与「用默认应用打开」的交接。它是库而非插件:没有 `ctx`、无状态、不发事件。 +`dsh-native-command` 无需 shell 即可运行 Host 可执行文件,并通过桌面打开 Host 文件系统路径。命令运行器捕获 utf8 输出、传播取消,并隐藏 Windows 瞬时控制台。路径打开器支持默认应用与文本编辑器意图、浏览器可渲染文档、WSL 转换与桌面可用性检查。它是库而非插件:没有 `ctx`、无状态、不发事件。 ## 目录 @@ -43,6 +43,10 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s `NativeCommandRunner` 类型是宿主集成的可注入命令边界:在集成需要一个可测试接缝的位置传入该函数(或其包装层),测试即可替换为假运行器。 +### 打开 Host 路径 + +`openNativePath(path, signal)` 将路径交给默认应用;平台能够确定默认浏览器时,HTML 与 SVG 会优先交给该浏览器。`openNativeTextFile(path, signal)` 选择文本编辑器意图;macOS 使用 `open -t`。WSL 路径先通过 `wslpath -w` 转换,再交给 Windows 桌面。`canOpenNativePath()` 报告当前 Host 是否可能具备桌面目标。 + ----- @@ -51,13 +55,15 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s
    实现细节——点击展开 -本运行器是 Node `execFile` 的薄包装,固定三项选择:utf8 编码、中止传播与 Windows 控制台隐藏。 +命令运行器是 Node `execFile` 的薄包装。路径打开器根据平台与环境事实选择一条无 shell 命令,而调用方继续负责决定允许打开哪个路径。 ### 源码地图 | 文件 | 职责 | |---|---| -| [`src/index.ts`](src/index.ts) | `runNativeCommand` 与 `NativeCommandRunner` 类型——即整个包 | +| [`src/index.ts`](src/index.ts) | 命令运行器与路径打开器的公共导出 | +| [`src/runner.ts`](src/runner.ts) | 无 shell 的 `execFile` 适配器 | +| [`src/path-opener.ts`](src/path-opener.ts) | 桌面探测、打开意图、浏览器偏好与 WSL 转换 | | [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;每次运行都是一次无状态的子进程往返) | ### execFile 给了运行器什么 @@ -74,7 +80,8 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s 当你需要消费方或本工具刻意不属于的通用子进程能力时,阅读以下页面。 - [原生目录选择器](../../host/directory-picker-native/README.zh.md)——本运行器执行的 OS 选择器命令。 -- [宿主 API 代理](../../host/apiproxy/README.zh.md)——本运行器服务的「用默认应用打开」交接。 +- [Session Controller](../../api/session-controller/README.zh.md)——打开前解析 Session 相对 workspace 路径。 +- [Settings Controller](../../api/settings-controller/README.zh.md)——选择 settings 文档与 agent-preset 目录。 - [子进程能力](../../subprocess/subprocess/README.zh.md)——通用子进程 seam,本包并非其组成部分。 ----- @@ -82,7 +89,7 @@ const { stdout, stderr } = await runNativeCommand('osascript', ['-e', script], s ## 模型体验 -无:宿主侧子进程运行器不注册任何面向模型的内容。 +无:宿主侧工具不注册任何面向模型的内容。 #### KV Cache 影响 diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 285c468c95..43dc4c7657 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,6 +1,6 @@ { "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", + "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts similarity index 99% rename from packages/host/apiproxy/tests/native-path-opener.spec.ts rename to packages/util/native-command/tests/path-opener.spec.ts index cf4489f864..d5cd1bb751 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -1,3 +1,4 @@ +/** Cross-platform native path opener behavior. */ type ExecFileCallback = ( error: (Error & { code?: string | number }) | null, stdout: string, @@ -16,7 +17,7 @@ vi.mock('node:child_process', () => ({ execFile: execFileMock })) import { release as osRelease } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/native-path-opener.ts' +import { canOpenNativePath, openNativePath, openNativeTextFile, type PathOpenerRunner } from '../src/index.ts' const signal = () => new AbortController().signal diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7aec63d5d..4eab46efb1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -777,6 +777,9 @@ importers: '@deepseek-ai/dsh-client-store': specifier: workspace:^ version: link:../../client/store + '@deepseek-ai/dsh-file-reference': + specifier: workspace:^ + version: link:../../context/file-reference '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -786,6 +789,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-permission-presets': specifier: workspace:^ version: link:../../interaction/permission-presets @@ -810,6 +816,9 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../skill/skill '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent @@ -831,6 +840,9 @@ importers: packages/api/settings-controller: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 @@ -838,12 +850,18 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent-presets': + specifier: workspace:^ + version: link:../../preset/agent-presets '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2047,9 +2065,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-util-workspace-path': - specifier: workspace:^ - version: link:../../util/workspace-path '@types/react': specifier: ~18.3.1 version: 18.3.31 @@ -2634,9 +2649,6 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller - '@deepseek-ai/dsh-client-connection': - specifier: workspace:^ - version: link:../connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -3067,9 +3079,6 @@ importers: '@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-locale': specifier: workspace:^ version: link:../locale @@ -4024,9 +4033,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol packages/context/file-reference-local: dependencies: @@ -5811,21 +5817,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../interaction/commands - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -5835,12 +5829,6 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-skill': - specifier: workspace:^ - version: link:../../skill/skill '@deepseek-ai/dsh-util-crypto': specifier: workspace:^ version: link:../../util/crypto @@ -5857,15 +5845,15 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent-presets': - specifier: workspace:^ - version: link:../../preset/agent-presets '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-settings': + specifier: workspace:^ + version: link:../../settings/settings '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../../typert/protocol @@ -6299,6 +6287,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -6315,6 +6306,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout + '@deepseek-ai/dsh-typert-protocol': + specifier: workspace:^ + version: link:../../typert/protocol packages/llm/llm-deepseek: dependencies: diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d9870dabc7..5c2fcca659 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -92,10 +92,12 @@ export const SERVICE_PAGE: Record = { sandboxPolicy: 'sandbox.md', sessionPersistence: 'persistence.md', sessionQuery: 'session-query.md', + sessionFileReferences: 'session-reference.md', sessionReferenceResolver: 'session-reference.md', sessionProjectionCache: 'session-projection.md', sessionProjections: 'session-projection.md', sessionController: 'session.md', + sessionSkillCatalog: 'skills.md', sessions: 'session.md', settings: 'settings.md', sessionTitle: 'session-title.md', @@ -318,6 +320,9 @@ export const LINK_MAP: Readonly> = { SessionId: 'core.md', SessionListRequest: 'session.md', SessionListValue: 'session.md', + ModelCatalog: 'session.md', + SessionOpenWorkspacePathRequest: 'session.md', + SessionOpenWorkspacePathValue: 'session.md', SessionModels: 'session.md', SessionModelsRequest: 'session.md', SessionPage: 'session.md', @@ -533,12 +538,16 @@ export const LINK_MAP: Readonly> = { SettingsScope: 'settings.md', SettingsDescriptor: 'settings.md', SettingsDescribeValue: 'settings.md', + SettingsDocumentOpenValue: 'settings.md', + AgentPresetDirectoryOpenValue: 'settings.md', SettingsNamespaceView: 'settings.md', SettingsPathOpView: 'settings.md', SettingsSecretView: 'settings.md', SettingsPathOp: 'settings.md', SettingsDescribeOptions: 'settings.md', SettingsUpdateSource: 'settings.md', + SkillListRequest: 'skills.md', + SkillListValue: 'skills.md', AuthorizationEntry: 'credentials.md', AuthorizationFlow: 'credentials.md', AuthorizationInteraction: 'credentials.md', diff --git a/scripts/gen-cordis-inspect-catalog.ts b/scripts/gen-cordis-inspect-catalog.ts index df3dfd3ed1..cb5ae60b65 100644 --- a/scripts/gen-cordis-inspect-catalog.ts +++ b/scripts/gen-cordis-inspect-catalog.ts @@ -17,7 +17,7 @@ const CLIENT_SERVICES: Readonly> = { theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'], uiWorkspace: [ 'connectWorkspace', 'startSession', 'archiveSession', 'pickDirectory', 'listDirectory', - 'createDirectory', 'openPath', + 'createDirectory', ], workspaces: ['create', 'rename', 'delete', 'insertSessionBefore', 'archiveSession'], } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 25c60c5b52..1119f7be56 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -154,8 +154,21 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'api-session-controller', title: 'Host Session Remote controller', mode: 'core', - consumers: ['host-apiproxy'], - note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.', + note: 'Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy.', + }, + { + key: 'sessionFileReferences', + pkg: 'api-session-controller', + title: 'Session-addressed file-reference Remote adapter', + mode: 'core', + note: 'Delegates file-reference discovery through the Session Controller\'s established Agent lookup policy.', + }, + { + key: 'sessionSkillCatalog', + pkg: 'api-session-controller', + title: 'Session-addressed skill Remote adapter', + mode: 'core', + note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.', }, { key: 'credentialsController', @@ -308,7 +321,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'File reference discovery', mode: 'seam', implementations: ['file-reference-local'], - note: 'The interface returns path-only completion candidates within the addressed Agent cwd through its unary Remote contract; providers own namespace access and ranking without reading file contents.', + consumers: ['api-session-controller'], + note: 'The interface returns path-only completion candidates within an Agent cwd; providers own namespace access and ranking without reading file contents.', }, { key: 'sessionReferenceResolver', From 674301721cd15852fd38bdf405b33c3693ad2b94 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:06:20 +0800 Subject: [PATCH 098/130] fix(build): correct workspace dependency declarations --- packages/api/settings-controller/package.json | 3 +-- packages/context/time-context/package.json | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 4f4326f47e..4de5f8d490 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -70,7 +70,6 @@ "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^" } } diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index af2c0e4672..4ed058608b 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -37,6 +37,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, From 88f2f0aaecda19812f7ef7d384a8f62e65386b1b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:12:24 +0800 Subject: [PATCH 099/130] test(api): complete migrated Remote coverage --- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- .../tests/session-models.host.spec.ts | 4 +- .../session-open-workspace-path.host.spec.ts | 56 ++++++++++- .../tests/session-skills.host.spec.ts | 37 +++++++ .../session-controller/tests/test-remote.ts | 3 + .../tests/settings-controller.host.spec.ts | 96 +++++++++++++++++++ .../tests/store.client.spec.ts | 15 +++ .../tests/apply.client.spec.ts | 8 +- .../tests/stores.client.spec.ts | 36 +++---- 11 files changed, 234 insertions(+), 31 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 73c0c75403..00810b4551 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: e0376d865fac1505cce48f4f3a678a11730ecd0e -module-graph.zh.md: 72af3b9a52764e0ae8ed2b7cef910eeedefa6c6c +module-graph.md: aebb6883280d5edc48355e83936cb8793529fc2a +module-graph.zh.md: 43607e291b632d1df6cd00a59e182027dc548008 diff --git a/docs/module-graph.md b/docs/module-graph.md index e0376d865f..aebb688328 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -549,6 +549,7 @@ flowchart TD pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants @@ -1789,7 +1790,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 72af3b9a52..43607e291b 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -551,6 +551,7 @@ flowchart TD pkg_file_reference --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants + pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants @@ -1791,7 +1792,7 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index d203585459..bc67a94ca6 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -305,9 +305,9 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) - const catalog = await buildModelCatalog(ctx) + const catalog = expectValue(await remote.modelCatalog()) expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deepseek-official', model: 'private-preview', diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index f8c89efd5e..ca7a78c589 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -2,7 +2,11 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' -import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' +import { + createSessionTestController, + createSessionTestRemote, + testSessionPersistence, +} from './test-remote.ts' async function context(): Promise { const ctx = new Context() @@ -92,4 +96,54 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' }, aborted.signal)) .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) + + it('classifies inspection cancellation and non-session failures', async () => { + const ctx = await context() + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + const inspect = vi.spyOn(controller, 'inspect') + const aborted = new AbortController() + inspect.mockImplementationOnce(async () => { + aborted.abort(new Error('cancelled')) + throw new Error('inspection stopped') + }) + await expect(controller.openWorkspacePath({ + sessionId: SessionId('inspection-cancelled'), path: 'result.html', + }, aborted.signal)).rejects.toMatchObject({ failure: { code: 'cancelled' } }) + + inspect.mockRejectedValueOnce('storage offline') + await expect(controller.openWorkspacePath({ + sessionId: SessionId('inspection-failed'), path: 'result.html', + }, new AbortController().signal)).rejects.toMatchObject({ + failure: { code: 'internal', message: expect.stringContaining('storage offline') }, + }) + }) + + it('classifies opener cancellation and non-Error failures', async () => { + const ctx = await context() + const sessionId = SessionId('open-error-kinds') + ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } }) + const aborted = new AbortController() + const openPath = vi.fn() + .mockImplementationOnce(async () => { + aborted.abort(new Error('cancelled')) + throw new Error('opening stopped') + }) + .mockRejectedValueOnce('desktop unavailable') + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath, + }) + + await expect(controller.openWorkspacePath({ sessionId, path: 'first.html' }, aborted.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + await expect(controller.openWorkspacePath({ + sessionId, path: 'second.html', + }, new AbortController().signal)).rejects.toMatchObject({ + failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, + }) + }) }) diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index e0640ddfe6..32d9957d4b 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -188,4 +188,41 @@ describe('SessionSkillCatalog', () => { failure: { code: 'internal', message: expect.stringContaining('skill registry is absent') }, }) }) + + it('rejects observations without projections or a project cwd', async () => { + const ctx = await context() + const sessionId = SessionId('incomplete-skills') + const withoutProjections = { ...observation(sessionId, { cwd: '/project' }), projections: undefined } + const observeSession = vi.fn() + .mockResolvedValueOnce(withoutProjections) + .mockResolvedValueOnce(observation(sessionId)) + ctx.provide('sessionQuery', { observeSession } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: expect.stringContaining('projected Session observation') }, + }) + await expect(catalog.list({ sessionId }, new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: expect.stringContaining('has no project cwd') }, + }) + }) + + it('classifies a provider listing failure', async () => { + const ctx = await context() + const sessionId = SessionId('failed-skills') + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(observation(sessionId, { cwd: '/project' })), + } as never) + ctx.provide('skills', { + list: () => Promise.reject(new Error('catalog offline')), + } as never) + const catalog = new SessionSkillCatalog(ctx) + + await expect(catalog.list({ sessionId }, new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: 'skill listing failed: Error: catalog offline' }, + }) + }) }) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index f5766382cb..77e813b324 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -19,6 +19,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import SessionController from '../src/index.ts' import type { + ModelCatalog, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -54,6 +55,7 @@ export interface TestSessionRemote { search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> selectModel(request: SessionSelectModelRequest): Promise> + modelCatalog(): Promise> rename(request: SessionRenameRequest): Promise> fork(request: SessionForkRequest): Promise> prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> @@ -241,6 +243,7 @@ export function createSessionTestRemote( ), create: request => remoteResult(() => direct.create(request)), selectModel: request => remoteResult(() => direct.selectModel(request)), + modelCatalog: () => remoteResult(() => direct.modelCatalog()), rename: request => remoteResult(() => direct.rename(request)), fork: request => remoteResult(() => direct.fork(request)), prompt: (request, signal = new AbortController().signal) => remoteResult( diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index 4b3b973eae..d43232ae62 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { + InvalidPresetIdError, + PresetExistsError, + UnknownPresetError, +} from '@deepseek-ai/dsh-agent-presets' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings' import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol' @@ -306,6 +311,32 @@ describe('the settings Remote namespace a configuration page calls', () => { }) }) + it('classifies cancellation while preparing or opening the settings document', async () => { + const preparing = new Context() + await preparing.plugin(DocumentSettings) + const prepareAbort = new AbortController() + vi.spyOn(preparing.settings, 'prepareDocument').mockImplementation(async () => { + prepareAbort.abort(new Error('cancelled')) + throw new Error('preparation stopped') + }) + const preparingController = new SettingsController(preparing) + await expect(preparingController.openSettingsDocument(prepareAbort.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + + const opening = new Context() + await opening.plugin(DocumentSettings) + vi.spyOn(opening.settings, 'prepareDocument').mockResolvedValue('/tmp/settings.yaml') + const openAbort = new AbortController() + const openingController = new SettingsController(opening, {}, { + openTextFile: async () => { + openAbort.abort(new Error('cancelled')) + throw new Error('opening stopped') + }, + }) + await expect(openingController.openSettingsDocument(openAbort.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + }) + it('opens a user Agent preset directory or returns its path without a native opener', async () => { const ctx = new Context() ctx.provide('agentPresets', { @@ -331,6 +362,21 @@ describe('the settings Remote namespace a configuration page calls', () => { .resolves.toEqual({ opened: false, path: '/presets/mine' }) }) + it('covers native-open detection defaults and explicit overrides', () => { + const fromInjectedOpener = new SettingsController(new Context(), {}, { + openPath: () => Promise.resolve(), + }) + expect((fromInjectedOpener as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(true) + + const detected = new SettingsController(new Context()) + expect(typeof (detected as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe('boolean') + + const override = vi.fn(() => false) + const overridden = new SettingsController(new Context(), {}, { canOpenPath: override }) + expect((overridden as unknown as { canOpenPath: () => boolean }).canOpenPath()).toBe(false) + expect(override).toHaveBeenCalledOnce() + }) + it('refuses a shipped Agent preset and a missing preset provider', async () => { const ctx = new Context() ctx.provide('agentPresets', { @@ -346,4 +392,54 @@ describe('the settings Remote namespace a configuration page calls', () => { await expect(missing.openAgentPresetDirectory('mine', new AbortController().signal)) .rejects.toMatchObject({ failure: { code: 'agent-preset-not-found' } }) }) + + it('rejects an empty Agent preset id before resolving a provider', async () => { + const resolve = vi.fn() + const ctx = new Context() + ctx.provide('agentPresets', { resolve } as never) + const controller = new SettingsController(ctx) + + await expect(controller.openAgentPresetDirectory('', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + expect(resolve).not.toHaveBeenCalled() + }) + + it.each([ + [new UnknownPresetError('missing', ['standard']), 'agent-preset-not-found'], + [new InvalidPresetIdError('../bad'), 'agent-preset-invalid'], + [new PresetExistsError('taken'), 'agent-preset-invalid'], + [new TypertRemoteFailure({ code: 'cancelled', message: 'cancelled', details: {} }), 'cancelled'], + ['unexpected preset failure', 'internal'], + ] as const)('maps Agent preset resolution failure %#', async (error, code) => { + const ctx = new Context() + ctx.provide('agentPresets', { resolve: () => Promise.reject(error) } as never) + const controller = new SettingsController(ctx) + + await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal)) + .rejects.toMatchObject({ failure: { code } }) + }) + + it('classifies cancellation and non-Error failures from the preset opener', async () => { + const ctx = new Context() + ctx.provide('agentPresets', { + resolve: (id: string) => Promise.resolve({ + id, trust: 'user', path: `/presets/${id}/agent.cordis.yml`, + }), + } as never) + const abort = new AbortController() + const openPath = vi.fn() + .mockImplementationOnce(async () => { + abort.abort(new Error('cancelled')) + throw new Error('opening stopped') + }) + .mockRejectedValueOnce('desktop unavailable') + const controller = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + + await expect(controller.openAgentPresetDirectory('first', abort.signal)) + .rejects.toMatchObject({ failure: { code: 'cancelled' } }) + await expect(controller.openAgentPresetDirectory('second', new AbortController().signal)) + .rejects.toMatchObject({ + failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, + }) + }) }) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 8ce1724b32..5f9aced5bb 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -181,6 +181,21 @@ describe('ModelsSettingsStore', () => { expect(store.store.getSnapshot().status).toBe('ready') }) + it('surfaces a configurable-provider directory failure', async () => { + const { face, mirror } = api() + const llm = (face as unknown as { + llm: { listConfigurableProviders: () => Promise> } + }).llm + llm.listConfigurableProviders = () => Promise.resolve(remoteFail('configuration directory down')) + const store = new ModelsSettingsStore(face, settingsSchema, mirror) + + await store.load() + + expect(store.store.getSnapshot()).toMatchObject({ + status: 'error', error: 'configuration directory down', + }) + }) + it('lets the newest load win over a stale slow response', async () => { let release: (() => void) | undefined const gate = new Promise((resolve) => { release = resolve }) diff --git a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts index 6d118856ef..6bc979abc6 100644 --- a/packages/client/ui-settings-plugins/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/apply.client.spec.ts @@ -29,7 +29,7 @@ async function bench(served?: string[]) { ctx.provide('locale', locale) const describeCredentials = vi.fn(() => Promise.resolve({ ok: false, error: { code: 'internal', message: 'no provider', details: {} } })) const models = vi.fn(() => Promise.resolve({ - rpcId: 'm', result: { ok: true, value: { groups: [], failures: [] } }, + ok: true as const, value: { groups: [], failures: [] }, })) const describeSettings = vi.fn(() => Promise.resolve(served === undefined ? { ok: false, error: { code: 'internal', message: 'no provider', details: {} } } @@ -45,11 +45,11 @@ async function bench(served?: string[]) { })) const remote = new TestRemote(ctx, { credentials: { describe: describeCredentials, set: vi.fn() }, + session: { modelCatalog: models }, settings: { describe: describeSettings }, }) ctx.provide('connection', { isLoopback: true, - api: { llm: { models } }, } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { @@ -66,7 +66,9 @@ function declareRoot(slots: SlotRegistry): () => void { describe('ui-settings-plugins apply', () => { it('declares the services it uses', () => { - expect(inject).toEqual(['slots', 'locale', 'connection', 'remote', 'remote.credentials', 'settingsScope']) + expect(inject).toEqual([ + 'slots', 'locale', 'connection', 'remote', 'remote.credentials', 'remote.session', 'settingsScope', + ]) }) it('registers one Plugins section and declares the tab and card slots', async () => { diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 28c26e73cd..2fefbb0cc8 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -65,12 +65,11 @@ function modelsApi(options: { error?: string } = {}) { const models = vi.fn(() => Promise.resolve({ - rpcId: 'm-1' as never, - result: options.error === undefined + ...(options.error === undefined ? { ok: true as const, value: { groups: options.groups ?? [], failures: options.failures ?? [] } } - : { ok: false as const, error: { code: 'internal_error' as never, message: options.error } }, + : { ok: false as const, error: { code: 'internal' as const, message: options.error, details: {} } }), })) - return { api: { llm: { models } } as never, models } + return { api: { modelCatalog: models } as never, models } } function deferred() { @@ -662,15 +661,14 @@ describe('SubagentModelSelectionCardController', () => { const refreshed = deferred() const models = vi.fn() .mockResolvedValueOnce({ - rpcId: 'catalog-1', - result: { ok: true, value: { + ok: true, value: { groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], failures: [], - } }, + }, }) .mockImplementationOnce(() => refreshed.promise) const controller = new SubagentModelSelectionCardController( - host.scope, { llm: { models } } as never, + host.scope, { modelCatalog: models } as never, ) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -684,8 +682,7 @@ describe('SubagentModelSelectionCardController', () => { candidates: [expect.objectContaining({ key: 'alpha\0fast', selected: true })], }) refreshed.resolve({ - rpcId: 'catalog-2', - result: { ok: true, value: { groups: [], failures: [] } }, + ok: true, value: { groups: [], failures: [] }, } as never) await vi.waitFor(() => { expect(state().catalogStatus).toBe('ready') }) expect(state().candidates).toEqual([ @@ -738,21 +735,19 @@ describe('SubagentModelSelectionCardController', () => { }) const models = vi.fn() .mockResolvedValueOnce({ - rpcId: 'catalog-1', - result: { ok: true, value: { + ok: true, value: { groups: [{ id: 'alpha', name: 'Alpha', models: [{ id: 'fast', name: 'Fast' }] }], failures: [], - } }, + }, }) .mockResolvedValueOnce({ - rpcId: 'catalog-2', - result: { ok: true, value: { + ok: true, value: { groups: [{ id: 'beta', name: 'Beta', models: [{ id: 'new', name: 'New' }] }], failures: [], - } }, + }, }) const controller = new SubagentModelSelectionCardController( - host.scope, { llm: { models } } as never, + host.scope, { modelCatalog: models } as never, ) const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) @@ -807,7 +802,7 @@ describe('SubagentModelSelectionCardController', () => { const pending = deferred() const models = vi.fn(() => pending.promise) - const controller = new SubagentModelSelectionCardController(host.scope, { llm: { models } } as never) + const controller = new SubagentModelSelectionCardController(host.scope, { modelCatalog: models } as never) const face = controller.inject() face.toggleEnabled() face.retryCatalog() @@ -819,14 +814,13 @@ describe('SubagentModelSelectionCardController', () => { const pendingResolve = deferred() const resolving = new SubagentModelSelectionCardController( host.scope, - { llm: { models: () => pendingResolve.promise } } as never, + { modelCatalog: () => pendingResolve.promise } as never, ) const resolvingFace = resolving.inject() resolvingFace.toggleEnabled() resolving.dispose() pendingResolve.resolve({ - rpcId: 'late' as never, - result: { ok: true, value: { groups: [], failures: [] } }, + ok: true, value: { groups: [], failures: [] }, } as never) await pendingResolve.promise }) From 812556040da79dc4a18a50d79bb393508019e84b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:37:25 +0800 Subject: [PATCH 100/130] fix(api): restore migrated remote coverage --- packages/client/ui-skill/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 2 +- .../host/apiproxy/tests/fetch-carrier.spec.ts | 10 ++++ packages/llm/llm/tests/service.spec.ts | 11 +++- packages/llm/llm/tests/topology.spec.ts | 52 +++++++++++++++++++ 5 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts index 5b6a9bb44e..36d1e9778c 100644 --- a/packages/client/ui-skill/src/client/index.ts +++ b/packages/client/ui-skill/src/client/index.ts @@ -58,7 +58,7 @@ interface CatalogFetch { } /** Required services: reference source faces plus the tool-row and locale registries. */ -export const inject = ['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote'] +export const inject = ['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills'] /** * Client plugin body: register the '/' source, dictionaries, and keyed tool row. diff --git a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts index 208baac3bc..89111675f3 100644 --- a/packages/client/ui-skill/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.client.spec.ts @@ -102,7 +102,7 @@ const req = (query: string, signal?: AbortSignal) => describe('apply', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote']) + expect(inject).toEqual(['inputTriggers', 'connection', 'sessions', 'slots', 'locale', 'remote', 'remote.skills']) }) it('registers the dedicated skill row and its locale dictionaries', async () => { diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 2cf89e675d..844c4d5039 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -81,6 +81,16 @@ describe('handler carrier-layer statuses', () => { expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) }) + it('rejects a request whose envelope method does not match its path', async () => { + const body = JSON.stringify({ type: 'client-request', rpcId: 'r-mismatch', method: 'other.method', payload: {} }) + const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) + const parsed = await response.json() as { result: { error?: { code: string; message: string } } } + expect(parsed.result.error).toMatchObject({ + code: 'bad-request', + message: 'method "other.method" does not match path "host.describe"', + }) + }) + it('500s when the impl itself throws', async () => { const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' })) const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 4c624fa862..47309daea1 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -525,13 +525,20 @@ describe('LlmRuntime', () => { const ctx = new Context() await ctx.plugin(LlmRuntime) const provider = { id: 'catalog', name: 'Catalog Provider' } - const model = { provider: 'catalog', id: 'fast', name: 'Fast', description: 'Low latency' } + const model = { + provider: 'catalog', + id: 'fast', + name: 'Fast', + description: 'Low latency', + inputModalities: ['text'] as const, + } ctx.llm.registerAdapter(['catalog'], new CatalogAdapter(provider, [model])) const providers = ctx.llm.listProviders() const models = await ctx.llm.listModels('catalog') expect(providers).toEqual([provider]) expect(models).toEqual([model]) + expect(models[0]!.inputModalities).not.toBe(model.inputModalities) providers[0]!.name = 'mutated' models[0]!.name = 'mutated' @@ -539,7 +546,7 @@ describe('LlmRuntime', () => { model.name = 'source mutated' expect(ctx.llm.listProviders()).toEqual([{ id: 'catalog', name: 'Catalog Provider' }]) await expect(ctx.llm.listModels('catalog')).resolves.toEqual([{ - provider: 'catalog', id: 'fast', name: 'source mutated', description: 'Low latency', + provider: 'catalog', id: 'fast', name: 'source mutated', description: 'Low latency', inputModalities: ['text'], }]) }) diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 8759e5ba69..0fbe8255e6 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -250,6 +250,58 @@ describe('model discovery registry', () => { ]) }) + it('carries cancellation into Remote discovery and maps provider failures', async () => { + const ctx = await setup() + const discover = vi.fn() + .mockResolvedValueOnce([ + { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 }, + { id: '' }, + { id: 'keep' }, + { id: 'bare' }, + ]) + .mockRejectedValueOnce(new Error('endpoint offline')) + .mockRejectedValueOnce('provider refused') + ctx.llm.registerModelDiscovery('llm-example', discover) + const signal = new AbortController().signal + + await expect(ctx.llm.remoteDiscoverModels( + 'llm-example', + { baseURL: 'https://gateway.example/v1' }, + signal, + )).resolves.toEqual([ + { id: 'keep', name: 'Keep', contextWindow: 1024, maxTokens: 256 }, + { id: 'bare' }, + ]) + expect(discover).toHaveBeenNthCalledWith( + 1, + { baseURL: 'https://gateway.example/v1' }, + signal, + ) + + await expect(ctx.llm.remoteDiscoverModels( + 'llm-example', + { baseURL: 'https://gateway.example/v1' }, + signal, + )).rejects.toMatchObject({ + failure: { + code: 'model-discovery-failed', + message: 'endpoint offline', + details: { settingsNs: 'llm-example', baseURL: 'https://gateway.example/v1' }, + }, + }) + await expect(ctx.llm.remoteDiscoverModels( + 'llm-example', + { provider: 'known-route' }, + signal, + )).rejects.toMatchObject({ + failure: { + code: 'model-discovery-failed', + message: 'provider refused', + details: { settingsNs: 'llm-example' }, + }, + }) + }) + it('refuses a namespace nothing serves and a draft with no endpoint', async () => { const ctx = await setup() ctx.llm.registerModelDiscovery('llm-example', () => Promise.resolve([])) From 5f6293e67a4cc52085db948b163a8aa4d00e832b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:48:58 +0800 Subject: [PATCH 101/130] test(client): update remote session fixtures --- .../tests/assembly-surfaces.client.spec.tsx | 10 +++++--- .../tests/chat-code-subcalls.client.spec.tsx | 11 ++++---- .../tests/toolview-slot.client.spec.tsx | 25 +++++++++---------- .../tests/api-catalog.client.spec.ts | 1 - 4 files changed, 24 insertions(+), 23 deletions(-) diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index f6e0ef3098..63cea1a148 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -10,7 +10,7 @@ import { apply as applyChat, inject as injectChat, type ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, usePinnedBrowserLanguages, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, 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 { toolSessionEvents } from './tool-details-render.client.tsx' @@ -76,13 +76,15 @@ async function bench(nodes: ToolResultNode[]) { isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) - // ui-theme's Appearance row binds a durable scope through these two. - runtime.ctx.provide('remote', { $on: () => () => {} }) + new TestRemote(runtime.ctx, { + session: { + openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), + }, + }) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), - openPath: vi.fn(async () => {}), } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index d2f8ccec8c..be9061fbc2 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -7,7 +7,7 @@ import type { ChatSnapshot, RunningToolCall, ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { @@ -116,9 +116,10 @@ async function bench(snapshot: ChatSnapshot) { snapshot: { running: snapshot.legacy.runningCalls.length > 0 }, }) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } - const openPath = vi.fn(async () => {}) + const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) ctx.provide('layout', layout as never) - ctx.provide('uiWorkspace', { openPath } as never) + ctx.provide('uiWorkspace', {} as never) + new TestRemote(ctx, { session: { openWorkspacePath } }) ctx.provide('connection', { api: { settings: {} }, isLoopback: false, @@ -132,7 +133,7 @@ async function bench(snapshot: ChatSnapshot) { await runtime.root.declare(ROOT_CHILDREN, AppRoot) await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) - return { runtime, layout, openPath } + return { runtime, layout, openWorkspacePath } } function mountApp(runtime: SlotTestRuntime) { @@ -222,7 +223,7 @@ describe('run_code sub-calls through the real chat machinery', () => { view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.openPath).toHaveBeenCalledWith('notes/demo.txt') + expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: SID, path: 'notes/demo.txt' }) }) view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 54a165cf40..ad5eb90a10 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -8,7 +8,7 @@ import { apply as applyChat, inject as injectChat, type ToolResultNode, } from '@deepseek-ai/dsh-client-ui-chat/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' -import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' +import { SlotTestRuntime, TestRemote, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { LocaleRuntime } 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' @@ -64,16 +64,13 @@ async function bench(nodes: ToolResultNode[]) { isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) - // ui-theme's Appearance row binds a durable scope through these two. - runtime.ctx.provide('remote', { $on: () => () => {} }) + const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) + new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } runtime.ctx.provide('layout', layout) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), - openPath: async (path: string) => { - runtime.workspaces.calls.push({ method: 'openPath', args: [path] }) - }, } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) @@ -91,7 +88,7 @@ async function bench(nodes: ToolResultNode[]) { await runtime.mount({ inject: [...injectConversation], apply: applyConversation }) await runtime.mount({ inject: [...injectChat], apply: applyChat }) await runtime.mount({ inject: [...injectTool], apply: applyTool }) - return { runtime, slots: runtime.slots, layout } + return { runtime, slots: runtime.slots, layout, openWorkspacePath } } describe('keyed toolview hole through the real machinery', () => { @@ -134,13 +131,13 @@ describe('keyed toolview hole through the real machinery', () => { await b.runtime.dispose() }) - it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => { + it('file-path clicks travel owner openFile → chat inject → session.openWorkspacePath', async () => { const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')]) const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) + expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: SID, path: 'src/a.ts' }) }) await b.runtime.dispose() }) @@ -150,7 +147,7 @@ describe('keyed toolview hole through the real machinery', () => { const view = b.runtime.renderRoot() view.getByText('Build').click() expect(b.layout.openDetails).not.toHaveBeenCalled() - expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false) + expect(b.openWorkspacePath).not.toHaveBeenCalled() await b.runtime.dispose() }) @@ -214,13 +211,15 @@ describe('registrant declaration injection', () => { isLoopback: false, hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) - // ui-theme's Appearance row binds a durable scope through these two. - runtime.ctx.provide('remote', { $on: () => () => {} }) + new TestRemote(runtime.ctx, { + session: { + openWorkspacePath: vi.fn(async () => ({ ok: true, value: { opened: true } })), + }, + }) runtime.ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) runtime.ctx.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() }) runtime.ctx.provide('uiWorkspace', { connectWorkspace: vi.fn(async () => SID), - openPath: vi.fn(async () => {}), } as never) const locale = new LocaleRuntime(runtime.ctx) runtime.ctx.provide('locale', locale) diff --git a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts index 019d34ac90..3dbfa80b05 100644 --- a/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/api-catalog.client.spec.ts @@ -19,7 +19,6 @@ describe('Client Cordis inspect catalog', () => { 'pickDirectory(): Promise', 'listDirectory(path?: string, signal?: AbortSignal): Promise', 'createDirectory(path: string, name: string): Promise', - 'openPath(path: string): Promise', ]) }) From 89ee54ebb7e094314530272ff13ea9470b5e1549 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:00:42 +0800 Subject: [PATCH 102/130] test(api): align migrated client contracts --- .../session-open-workspace-path.host.spec.ts | 8 +++---- .../tests/session-skills.host.spec.ts | 21 ++++++++----------- packages/api/settings-controller/src/index.ts | 13 ++++++++---- .../tests/settings-controller.host.spec.ts | 12 ++++++----- .../client/connection/src/client/fixture.ts | 6 ++---- .../tests/stores.client.spec.ts | 8 +++---- 6 files changed, 35 insertions(+), 33 deletions(-) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ca7a78c589..2727fb96c2 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -114,11 +114,11 @@ describe('session/openWorkspacePath', () => { }, aborted.signal)).rejects.toMatchObject({ failure: { code: 'cancelled' } }) inspect.mockRejectedValueOnce('storage offline') - await expect(controller.openWorkspacePath({ + const failed = controller.openWorkspacePath({ sessionId: SessionId('inspection-failed'), path: 'result.html', - }, new AbortController().signal)).rejects.toMatchObject({ - failure: { code: 'internal', message: expect.stringContaining('storage offline') }, - }) + }, new AbortController().signal) + await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failed).rejects.toThrow('storage offline') }) it('classifies opener cancellation and non-Error failures', async () => { diff --git a/packages/api/session-controller/tests/session-skills.host.spec.ts b/packages/api/session-controller/tests/session-skills.host.spec.ts index 32d9957d4b..5b5e1b2a5a 100644 --- a/packages/api/session-controller/tests/session-skills.host.spec.ts +++ b/packages/api/session-controller/tests/session-skills.host.spec.ts @@ -183,10 +183,9 @@ describe('SessionSkillCatalog', () => { } as never) const catalog = new SessionSkillCatalog(ctx) - await expect(catalog.list({ sessionId }, new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: expect.stringContaining('skill registry is absent') }, - }) + const failed = catalog.list({ sessionId }, new AbortController().signal) + await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failed).rejects.toThrow('skill registry is absent') }) it('rejects observations without projections or a project cwd', async () => { @@ -199,14 +198,12 @@ describe('SessionSkillCatalog', () => { ctx.provide('sessionQuery', { observeSession } as never) const catalog = new SessionSkillCatalog(ctx) - await expect(catalog.list({ sessionId }, new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: expect.stringContaining('projected Session observation') }, - }) - await expect(catalog.list({ sessionId }, new AbortController().signal)) - .rejects.toMatchObject({ - failure: { code: 'internal', message: expect.stringContaining('has no project cwd') }, - }) + const unprojected = catalog.list({ sessionId }, new AbortController().signal) + await expect(unprojected).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(unprojected).rejects.toThrow('projected Session observation') + const cwdless = catalog.list({ sessionId }, new AbortController().signal) + await expect(cwdless).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(cwdless).rejects.toThrow('has no project cwd') }) it('classifies a provider listing failure', async () => { diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index a61dfd329e..393a1dec81 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -37,6 +37,11 @@ export type * from './types.ts' const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) +/** Read abort state afresh after an awaited provider or opener call. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + /** Native document-opening policy. */ export interface Config { /** Override platform desktop-opener detection. */ @@ -185,23 +190,23 @@ export class SettingsController extends TypertRemoteService { @Remote async openSettingsDocument(signal: AbortSignal): Promise { const settings = this.provider() - if (signal.aborted) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw cancelled('settings document open was aborted') let path: string | undefined try { path = await settings.prepareDocument() } catch (error: unknown) { - if (signal.aborted) throw cancelled('settings document preparation was aborted') + if (isAborted(signal)) throw cancelled('settings document preparation was aborted') throw internal(`settings document preparation failed: ${messageOf(error)}`) } if (path === undefined) { throw internal('settings provider has no local document to open') } - if (signal.aborted) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw cancelled('settings document open was aborted') try { await this.openTextFile(path, signal) return { opened: true } } catch (error: unknown) { - if (signal.aborted) throw cancelled('settings document open was aborted') + if (isAborted(signal)) throw cancelled('settings document open was aborted') throw internal(`path open failed: ${messageOf(error)}`) } } diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index d43232ae62..aa8945b4fc 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -263,13 +263,15 @@ describe('the settings Remote namespace a configuration page calls', () => { it('preserves settings-document absence, failure, and cancellation', async () => { const absent = await boot() - await expect(absent.controller.openSettingsDocument(new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'internal', message: expect.stringContaining('no local document') } }) + const missingDocument = absent.controller.openSettingsDocument(new AbortController().signal) + await expect(missingDocument).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(missingDocument).rejects.toThrow('no local document') const failed = await boot(DocumentSettings) vi.spyOn(failed.ctx.settings, 'prepareDocument').mockRejectedValue(new Error('read failed')) - await expect(failed.controller.openSettingsDocument(new AbortController().signal)) - .rejects.toMatchObject({ failure: { code: 'internal', message: expect.stringContaining('read failed') } }) + const failedRead = failed.controller.openSettingsDocument(new AbortController().signal) + await expect(failedRead).rejects.toMatchObject({ failure: { code: 'internal' } }) + await expect(failedRead).rejects.toThrow('read failed') const cancelled = new AbortController() cancelled.abort(new Error('cancelled')) @@ -412,7 +414,7 @@ describe('the settings Remote namespace a configuration page calls', () => { ['unexpected preset failure', 'internal'], ] as const)('maps Agent preset resolution failure %#', async (error, code) => { const ctx = new Context() - ctx.provide('agentPresets', { resolve: () => Promise.reject(error) } as never) + ctx.provide('agentPresets', { resolve: async () => { throw error } } as never) const controller = new SettingsController(ctx) await expect(controller.openAgentPresetDirectory('mine', new AbortController().signal)) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a5689069c2..e39c39db01 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -3657,12 +3657,10 @@ export class FixtureApiClient extends AbstractApiClient { /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */ private dispatch( - method: keyof RpcMethodMap, + _method: keyof RpcMethodMap, request: RpcRequest, ): Promise> { - switch (method) { - case 'host.describe': return this.api.host.describe(request) - } + return this.api.host.describe(request) } } diff --git a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts index 2fefbb0cc8..ffdb0d8cf3 100644 --- a/packages/client/ui-settings-plugins/tests/stores.client.spec.ts +++ b/packages/client/ui-settings-plugins/tests/stores.client.spec.ts @@ -668,7 +668,7 @@ describe('SubagentModelSelectionCardController', () => { }) .mockImplementationOnce(() => refreshed.promise) const controller = new SubagentModelSelectionCardController( - host.scope, { modelCatalog: models } as never, + host.scope, { modelCatalog: models }, ) const face = controller.inject() const state = () => face.hooks.subagentModelSelectionCard.getSnapshot() @@ -747,7 +747,7 @@ describe('SubagentModelSelectionCardController', () => { }, }) const controller = new SubagentModelSelectionCardController( - host.scope, { modelCatalog: models } as never, + host.scope, { modelCatalog: models }, ) const state = () => controller.inject().hooks.subagentModelSelectionCard.getSnapshot() await vi.waitFor(() => { expect(state().candidates[0]?.provider).toBe('alpha') }) @@ -802,7 +802,7 @@ describe('SubagentModelSelectionCardController', () => { const pending = deferred() const models = vi.fn(() => pending.promise) - const controller = new SubagentModelSelectionCardController(host.scope, { modelCatalog: models } as never) + const controller = new SubagentModelSelectionCardController(host.scope, { modelCatalog: models }) const face = controller.inject() face.toggleEnabled() face.retryCatalog() @@ -814,7 +814,7 @@ describe('SubagentModelSelectionCardController', () => { const pendingResolve = deferred() const resolving = new SubagentModelSelectionCardController( host.scope, - { modelCatalog: () => pendingResolve.promise } as never, + { modelCatalog: () => pendingResolve.promise }, ) const resolvingFace = resolving.inject() resolvingFace.toggleEnabled() From 72cf4fae83e67c5f042afd3de79b366eeb271e47 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:07:24 +0800 Subject: [PATCH 103/130] fix(settings): preserve config catalog source anchor --- packages/api/settings-controller/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index 393a1dec81..e822d7f489 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -37,17 +37,17 @@ export type * from './types.ts' const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) }) -/** Read abort state afresh after an awaited provider or opener call. */ -function isAborted(signal: AbortSignal): boolean { - return signal.aborted -} - /** Native document-opening policy. */ export interface Config { /** Override platform desktop-opener detection. */ readonly nativeOpen?: boolean } +/** Read abort state afresh after an awaited provider or opener call. */ +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + /** Host integrations replaceable by direct unit tests. */ export interface SettingsControllerInternals { readonly openPath?: (path: string, signal: AbortSignal) => Promise From 18ae39a6658d43cebfc4df859438e8df86898625 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:47:37 +0800 Subject: [PATCH 104/130] fix(api): preserve native path opening behavior --- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 8 +-- ...8-10-unary-apiproxy-remote-migration.zh.md | 8 +-- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 2 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 2 +- apps/web/tests/produced-files.e2e.ts | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 10 +-- docs/event-producer-consumer.zh.md | 10 +-- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 10 +-- docs/subsystems/session.zh.md | 10 +-- knip.json | 5 -- packages/api/session-controller/src/index.ts | 34 ++-------- packages/api/session-controller/src/types.ts | 5 +- .../session-open-workspace-path.host.spec.ts | 66 ++++--------------- .../api/session-controller/tsconfig.host.json | 1 - .../client/connection/src/client/fixture.ts | 4 +- packages/client/ui-chat/package.json | 4 +- packages/client/ui-chat/src/client/apply.ts | 6 +- .../tests/apply-inject.client.spec.tsx | 2 +- packages/client/ui-chat/tsconfig.json | 3 + .../tests/chat-code-subcalls.client.spec.tsx | 2 +- .../tests/toolview-slot.client.spec.tsx | 2 +- .../extensions/tool-cordis/src/api-catalog.ts | 8 +-- packages/host/apiproxy/src/api-proxy.ts | 4 +- pnpm-lock.yaml | 3 + 31 files changed, 85 insertions(+), 150 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index 617d6b07db..c316742ec1 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.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-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: 027376cbf772043cce21f487c94288cad6bece1c -2026-08-10-unary-apiproxy-remote-migration.zh.md: f7507959a992dd17ca60883ada7769c7196fdc3a +2026-08-10-unary-apiproxy-remote-migration.md: 11254099556113d921da502f0150522886a718f3 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 50b303876863e992566f6ed6fb0bd0a89326344f diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md index 027376cbf7..1125409955 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -8,7 +8,7 @@ English | [中文](2026-08-10-unary-apiproxy-remote-migration.zh.md) The Host API Proxy duplicated simple unary operations across business Services, API Proxy interfaces, Zod schemas, route tables, client stubs, and Client callers. [Typert Remote calls](2026-08-02-typert-remote-method-calls.md) already let a business package own this class of call, but moving an endpoint without its lifecycle and projection policy could change observable behavior. -Agent-bound calls require particular care. Shared lookup policy reuses live Agents, resumes ordinary cold Sessions with their recorded presets, deduplicates concurrent resumes, and rejects subagent-owned identities. Skill listing instead must inspect a Session without activating its Agent. Native desktop operations must keep the browser from choosing an arbitrary Host path. +Agent-bound calls require particular care. Shared lookup policy reuses live Agents, resumes ordinary cold Sessions with their recorded presets, deduplicates concurrent resumes, and rejects subagent-owned identities. Skill listing instead must inspect a Session without activating its Agent. Settings and preset operations keep their Host-owned document paths out of browser requests; Session file links preserve their caller-resolved path behavior. ## Decision @@ -30,11 +30,11 @@ Simple unary operations live on their natural business Remote owner. The busines | `workspace.list`, `workspace.insertSessionBefore`, `workspace.archiveSession` | Equivalent `workspace/*` methods | The Workspace registry owns detached snapshots and serialized mutations. | | `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | -| `host.openPath` | `session/openWorkspacePath` | `SessionController` resolves the path against the addressed Session's workspace before native opening. | +| `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. | The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. -The native path implementation lives in `@deepseek-ai/dsh-native-command`. Session and Settings controllers select the target; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. +The native path implementation lives in `@deepseek-ai/dsh-native-command`. Settings controllers select Host-owned targets, while Session-aware Clients resolve workspace paths before calling `SessionController`; the utility only performs platform detection, WSL translation, browser preference, text-editor intent, and shell-free command execution. ## Browser authentication @@ -50,7 +50,7 @@ Focused Host and Client tests cover Remote calls, lookup and no-activation polic **Move every unary operation.** Rejected because `host.describe` combines deployment facts and Connection readiness, while Session export is a streamed download rather than a unary business method. -**Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports without making the browser authoritative for filesystem targets. +**Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports and duplicated platform logic. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index f7507959a9..50b3038768 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -8,7 +8,7 @@ Status: implemented Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由表、Client stub 与 Client 调用方之间重复定义简单一元操作。[Typert Remote 调用](2026-08-02-typert-remote-method-calls.zh.md)已经允许业务包持有这类调用,但如果迁移 endpoint 时没有一并保留生命周期与投影策略,就会改变可观察行为。 -与 Agent 绑定的调用需要格外谨慎。共享 lookup 策略会复用 live Agent、用记录的 preset 恢复普通冷 Session、对并发恢复去重,并拒绝由 subagent 持有的 identity。skill 列表则必须检查 Session 而不激活 Agent。原生桌面操作必须避免让浏览器选择任意 Host 路径。 +与 Agent 绑定的调用需要格外谨慎。共享 lookup 策略会复用 live Agent、用记录的 preset 恢复普通冷 Session、对并发恢复去重,并拒绝由 subagent 持有的 identity。skill 列表则必须检查 Session 而不激活 Agent。Settings 与 preset 操作不会把 Host 持有的文档路径放进浏览器请求;Session 文件链接保留由调用方解析路径的行为。 ## 决策 @@ -30,11 +30,11 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 | `workspace.list`、`workspace.insertSessionBefore`、`workspace.archiveSession` | 对应的 `workspace/*` 方法 | Workspace registry 持有脱离可变对象的 snapshot 与串行 mutation。 | | `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | -| `host.openPath` | `session/openWorkspacePath` | `SessionController` 先基于目标 Session 的 workspace 解析路径,再执行原生打开。 | +| `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 | 共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 -原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Session 与 Settings controller 选择目标;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 +原生路径实现在 `@deepseek-ai/dsh-native-command` 中。Settings controller 选择 Host 持有的目标,Session-aware Client 则在调用 `SessionController` 前解析 workspace 路径;该工具仅负责平台探测、WSL 转换、浏览器偏好、文本编辑器意图与无 shell 命令执行。 ## 浏览器认证 @@ -50,7 +50,7 @@ Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整 **迁移每一个一元操作。** 否决,因为 `host.describe` 组合部署事实与 Connection readiness,而 Session export 是流式下载,不是一元业务方法。 -**把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入,同时不让浏览器成为文件系统目标的权威。 +**把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入与重复的平台逻辑。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index e97143a911..fa703bf41d 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: c8ede5c5c2fbdc9797edd9bf80673442c39873c2 -2026-07-28-tool-call-file-open-in-os.zh.md: 1e51dd4dced25bd14272358199baef82f396635a +2026-07-28-tool-call-file-open-in-os.md: a8d5bd116b3f4cf1434d44b643dad3d883763a82 +2026-07-28-tool-call-file-open-in-os.zh.md: b486ec0972356419c2df5abbc148dc57a4586920 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index c8ede5c5c2..a8d5bd116b 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -10,7 +10,7 @@ Chat tool rows treated the whole summary line as a click target that opened the ## Decision -File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the Host resolves relative paths against the addressed Session's cwd. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. +File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the chat view resolves relative paths against the addressed Session's cwd when it is known. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. `session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `host.describe.canOpenPath` is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index 1e51dd4dce..b486ec0972 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`;Host 以目标 Session 的 cwd 为基准解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 +文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`;聊天视图会在目标 Session 的 cwd 已知时据此解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 `session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `host.describe.canOpenPath` 为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts index cdc3296693..0761639de5 100644 --- a/apps/web/tests/produced-files.e2e.ts +++ b/apps/web/tests/produced-files.e2e.ts @@ -158,7 +158,7 @@ describe('web e2e: a finished turn ends with the files it produced', () => { ]) expect(response.status()).toBe(200) expect(openPath).toHaveBeenCalledTimes(1) - expect(openPath.mock.calls[0]![0]).toMatchObject({ path: '.' }) + expect(openPath.mock.calls[0]![0]).toMatchObject({ path: `${scaffold.workspaceCwd}/.` }) } finally { openPath.mockRestore() } diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 2416c74af9..438a3ef9c8 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 0a08455a27fa94f1bb69628b61dd8eb23d318ded -config-catalog.zh.md: ad6a9c0481750ec41c0701d3a9e3989fd0e9ebf4 +config-catalog.md: 05bd17a600869782409038188457db6d362f07a4 +config-catalog.zh.md: 1095b31530a28af2c4a23520fd5e68e067b46c11 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 0a08455a27..05bd17a600 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -304,7 +304,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ad6a9c0481..1095b31530 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -306,7 +306,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:67`](../packages/api/session-controller/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 468327d243..749e1e0695 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 2c443095b796eb274fa099b4b7d91b4454311a37 -event-producer-consumer.zh.md: b35e1c56d7f51d3aed7f3f81aec228708cb952e3 +event-producer-consumer.md: 8c30d5a945e2abb21c1e8dad5d6aa4e472c18b23 +event-producer-consumer.zh.md: 29304d9ac855a36ffc8a5e6766bedc841cf2738c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 2c443095b7..8c30d5a945 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:538`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:518`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:545`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:524`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index b35e1c56d7..29304d9ac8 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:538`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:518`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:545`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:524`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:531`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:537`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:517`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:544`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 423b9a6668..f917871371 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: fe3f71de6aeb6b5d9924fa88c94688f7eac8ff0a -session.zh.md: 844fd7a057c853fcbda6add875b997069ab024de +session.md: 919a8eff583886610b56b34294ae1c13a06493b0 +session.zh.md: 8ffc7c8256311328c9e56e63625f3fbfcb5241a7 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index fe3f71de6a..919a8eff58 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -581,7 +581,7 @@ The backends that consume this contract are on [persistence.md](persistence.md). `ModelCatalog` is the Host-generation model directory returned by `session/modelCatalog`: it carries the deployment default, routable provider ids, successful provider groups, and isolated provider failures. It is not derived from one Session and remains separate from Session projections. -`SessionOpenWorkspacePathRequest` carries a `sessionId` and an absolute or Session-workspace-relative `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. The controller inspects the Session without activating its Agent, resolves a relative path against the recorded cwd, and reports missing Sessions, cancellation, and opener failures through the Session Remote error vocabulary. +`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. @@ -650,11 +650,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH @Remote('modelCatalog') modelCatalog(): Promise /** - * Open a path resolved against one Session's workspace on the Host desktop. - * @param request - Session identity and absolute or workspace-relative path. - * @param signal - caller lifetime; abort terminates inspection or the native command. + * Open one path prepared by a Session-aware caller on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. + * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 844fd7a057..8ffc7c8256 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -585,7 +585,7 @@ interface TurnEndReasonMap { `ModelCatalog` 是 `session/modelCatalog` 返回的 Host generation 模型目录:它携带部署默认值、可路由 provider id、成功的 provider 分组与相互隔离的 provider 失败。它不由某个 Session 派生,因此与 Session projection 分开保存。 -`SessionOpenWorkspacePathRequest` 携带 `sessionId` 与绝对路径或相对于 Session workspace 的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。controller 在不激活 Agent 的前提下检查 Session,基于记录的 cwd 解析相对路径,并通过 Session Remote 错误词汇表报告 Session 缺失、取消与打开器失败。 +`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 @@ -654,11 +654,11 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH @Remote('modelCatalog') modelCatalog(): Promise /** - * Open a path resolved against one Session's workspace on the Host desktop. - * @param request - Session identity and absolute or workspace-relative path. - * @param signal - caller lifetime; abort terminates inspection or the native command. + * Open one path prepared by a Session-aware caller on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. + * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise diff --git a/knip.json b/knip.json index b22ef3caaa..a1c247f0d5 100644 --- a/knip.json +++ b/knip.json @@ -460,11 +460,6 @@ "tests/**/*.ts" ] }, - "packages/context/file-reference": { - "ignoreDependencies": [ - "zod" - ] - }, "packages/context/session-reference": { "ignoreDependencies": [ "zod" diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index dadf1db607..b58cdfcbad 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -7,9 +7,7 @@ import { openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' -import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { - ApiSessionNotFound, ApiSessionAgentController, inspectApiSession, type ApiSessionAgentResult, @@ -245,11 +243,11 @@ export class SessionController extends TypertRemoteService { } /** - * Open a path resolved against one Session's workspace on the Host desktop. - * @param request - Session identity and absolute or workspace-relative path. - * @param signal - caller lifetime; abort terminates inspection or the native command. + * Open one path prepared by a Session-aware caller on the Host desktop. + * @param request - path after best-effort Session workspace resolution. + * @param signal - caller lifetime; abort terminates the native command. * @returns confirmation after the native opener accepts the path. - * @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails. + * @throws TypertRemoteFailure when the request is invalid, cancelled, or the opener fails. */ @Remote('openWorkspacePath') async openWorkspacePath( @@ -264,30 +262,8 @@ export class SessionController extends TypertRemoteService { }) } signal.throwIfAborted() - let cwd: string | undefined try { - cwd = (await this.inspect(request.sessionId, signal)).meta.cwd - } catch (error: unknown) { - if (signal.aborted) { - throw new TypertRemoteFailure({ - code: 'cancelled', message: 'path open was aborted', details: {}, - }) - } - if (error instanceof ApiSessionNotFound) { - throw new TypertRemoteFailure({ - code: 'session-not-found', - message: error.message, - details: { sessionId: request.sessionId }, - }) - } - throw new TypertRemoteFailure({ - code: 'internal', - message: `session "${request.sessionId}" could not be inspected: ${String(error)}`, - details: {}, - }) - } - try { - await this.openPath(resolveWorkspacePath(cwd, request.path), signal) + await this.openPath(request.path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) { diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 24e9232d07..167937e3d3 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -362,10 +362,9 @@ export interface SessionCancelValue { readonly accepted: true } -/** Session-addressed request to open one workspace path on the Host desktop. */ +/** Request to open one path prepared by a Session-aware caller on the Host desktop. */ export interface SessionOpenWorkspacePathRequest { - readonly sessionId: SessionId - /** Absolute or Session-workspace-relative path. */ + /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 2727fb96c2..ee24947227 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,11 +1,10 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import { createSessionTestController, createSessionTestRemote, - testSessionPersistence, } from './test-remote.ts' async function context(): Promise { @@ -16,10 +15,8 @@ async function context(): Promise { } describe('session/openWorkspacePath', () => { - it('resolves a relative path against the attached Session cwd', async () => { + it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() - const sessionId = SessionId('open-relative') - ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } }) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -28,18 +25,14 @@ describe('session/openWorkspacePath', () => { }) const signal = new AbortController().signal - await expect(remote.openWorkspacePath({ sessionId, path: 'src/a.ts' }, signal)) + await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal)) .resolves.toEqual({ ok: true, value: { opened: true } }) expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal) expect(ctx.agents.list()).toEqual([]) }) - it('preserves absolute paths and cwd-less Session paths', async () => { + it('preserves relative and absolute Host-resolvable paths', async () => { const ctx = await context() - const withCwd = SessionId('open-absolute') - const withoutCwd = SessionId('open-without-cwd') - ctx.sessions.create(withCwd, { meta: { cwd: '/workspace/project' } }) - ctx.sessions.create(withoutCwd) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -47,18 +40,13 @@ describe('session/openWorkspacePath', () => { openPath, }) - await remote.openWorkspacePath({ sessionId: withCwd, path: '/tmp/result.html' }) - await remote.openWorkspacePath({ sessionId: withoutCwd, path: 'result.html' }) + await remote.openWorkspacePath({ path: '/tmp/result.html' }) + await remote.openWorkspacePath({ path: 'result.html' }) expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html']) }) - it('rejects empty paths and missing Sessions before opening anything', async () => { + it('rejects empty paths before opening anything', async () => { const ctx = await context() - const sessionId = SessionId('open-validation') - ctx.provide('sessionPersistence', testSessionPersistence(ctx, { - list: () => Promise.resolve([]), - inspect: () => Promise.resolve(undefined), - }) as never) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), @@ -66,17 +54,13 @@ describe('session/openWorkspacePath', () => { openPath, }) - await expect(remote.openWorkspacePath({ sessionId, path: '' })) + await expect(remote.openWorkspacePath({ path: '' })) .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) - await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' })) - .resolves.toMatchObject({ ok: false, error: { code: 'session-not-found' } }) expect(openPath).not.toHaveBeenCalled() }) it('preserves native opener failure and cancellation results', async () => { const ctx = await context() - const sessionId = SessionId('open-failure') - ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } }) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.reject(new Error('desktop unavailable'))) const remote = createSessionTestRemote(ctx, { @@ -85,7 +69,7 @@ describe('session/openWorkspacePath', () => { openPath, }) - await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' })) + await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, error: { code: 'internal', message: 'path open failed: desktop unavailable' }, @@ -93,38 +77,12 @@ describe('session/openWorkspacePath', () => { const aborted = new AbortController() aborted.abort(new Error('cancelled')) - await expect(remote.openWorkspacePath({ sessionId, path: 'result.html' }, aborted.signal)) + await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal)) .resolves.toMatchObject({ ok: false, error: { code: 'cancelled' } }) }) - it('classifies inspection cancellation and non-session failures', async () => { - const ctx = await context() - const controller = createSessionTestController(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/default', - }) - const inspect = vi.spyOn(controller, 'inspect') - const aborted = new AbortController() - inspect.mockImplementationOnce(async () => { - aborted.abort(new Error('cancelled')) - throw new Error('inspection stopped') - }) - await expect(controller.openWorkspacePath({ - sessionId: SessionId('inspection-cancelled'), path: 'result.html', - }, aborted.signal)).rejects.toMatchObject({ failure: { code: 'cancelled' } }) - - inspect.mockRejectedValueOnce('storage offline') - const failed = controller.openWorkspacePath({ - sessionId: SessionId('inspection-failed'), path: 'result.html', - }, new AbortController().signal) - await expect(failed).rejects.toMatchObject({ failure: { code: 'internal' } }) - await expect(failed).rejects.toThrow('storage offline') - }) - it('classifies opener cancellation and non-Error failures', async () => { const ctx = await context() - const sessionId = SessionId('open-error-kinds') - ctx.sessions.create(sessionId, { meta: { cwd: '/workspace/project' } }) const aborted = new AbortController() const openPath = vi.fn() .mockImplementationOnce(async () => { @@ -138,10 +96,10 @@ describe('session/openWorkspacePath', () => { openPath, }) - await expect(controller.openWorkspacePath({ sessionId, path: 'first.html' }, aborted.signal)) + await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal)) .rejects.toMatchObject({ failure: { code: 'cancelled' } }) await expect(controller.openWorkspacePath({ - sessionId, path: 'second.html', + path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ failure: { code: 'internal', message: 'path open failed: desktop unavailable' }, }) diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index e3f40c885d..bea21672b7 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -44,7 +44,6 @@ { "path": "../../subagent/subagent" }, { "path": "../../typert/protocol" }, { "path": "../../typert/registry" }, - { "path": "../../util/workspace-path" }, { "path": "../../workspace/workspace" } ] } diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index e39c39db01..a2a7617a7e 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -3501,9 +3501,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) } case 'session/openWorkspacePath': { - const pathRequest = request as { readonly sessionId: SessionId; readonly path: string } - const missing = requireRemoteSession(pathRequest) - return missing ?? sessionOk({ opened: true as const }) + return sessionOk({ opened: true as const }) } case 'session/modelCatalog': return Promise.resolve({ ok: true, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index fcafb63b8b..10382a63bf 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -74,7 +74,8 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -104,6 +105,7 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 829b36bd0f..be061dff13 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -5,6 +5,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' import type { BoundActions, ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' // Type-only service and declaration merges used by the apply world. import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -117,7 +118,10 @@ export function apply(ctx: Context): void { }, fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner), openFile: async (path) => { - const result = await ctx.remote.session.openWorkspacePath({ sessionId, path }) + const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd + const result = await ctx.remote.session.openWorkspacePath({ + path: resolveWorkspacePath(cwd, path), + }) if (!result.ok) throw new Error(`path open failed: ${result.error.message}`) }, loadOlder: () => { void session.loadOlder() }, diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 2a0d9b045c..9ceef64dd7 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -123,7 +123,7 @@ describe('Chat inject API', () => { const b = await bench() const { injected } = b.chatViewApi(ROOT) await injected.openFile('src/a.ts') - expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: ROOT, path: 'src/a.ts' }) + expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: '/proj/src/a.ts' }) b.openWorkspacePath.mockResolvedValueOnce({ ok: false, diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 0800260fbb..4d42320885 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,6 +50,9 @@ { "path": "../../runtime-diagnostics/invariants" }, + { + "path": "../../util/workspace-path" + }, { "path": "../../session/session-stats" }, diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index be9061fbc2..a23e003800 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -223,7 +223,7 @@ describe('run_code sub-calls through the real chat machinery', () => { view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: SID, path: 'notes/demo.txt' }) + expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'notes/demo.txt' }) }) view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index ad5eb90a10..719dbafb1a 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -137,7 +137,7 @@ describe('keyed toolview hole through the real machinery', () => { view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.openWorkspacePath).toHaveBeenCalledWith({ sessionId: SID, path: 'src/a.ts' }) + expect(b.openWorkspacePath).toHaveBeenCalledWith({ path: 'src/a.ts' }) }) await b.runtime.dispose() }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index eb1992f66d..37d29ce704 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1384,10 +1384,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', - description: 'Open a path resolved against one Session\'s workspace on the Host desktop.', - parameters: [{ name: 'request', description: 'Session identity and absolute or workspace-relative path.' }, { name: 'signal', description: 'caller lifetime; abort terminates inspection or the native command.' }], + description: 'Open one path prepared by a Session-aware caller on the Host desktop.', + parameters: [{ name: 'request', description: 'path after best-effort Session workspace resolution.' }, { name: 'signal', description: 'caller lifetime; abort terminates the native command.' }], returns: 'confirmation after the native opener accepts the path.', - throws: ['TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails.'], + throws: ['TypertRemoteFailure when the request is invalid, cancelled, or the opener fails.'], }, { signature: '@Remote(\'rename\') rename(request: SessionRenameRequest): Promise', @@ -4960,7 +4960,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionOpenWorkspacePathRequest', - declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly sessionId: SessionId;\n readonly path: string;\n}', + declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}', }, { name: 'SessionOpenWorkspacePathValue', diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 29ebd6b732..6e502172f7 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -34,9 +34,7 @@ export interface ApiProxyDefaults { /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ sessionExportCompressionLevel?: SessionLogCompressionLevel /** - * Whether handing a path to the native opener can work at all — the - * `hasDocument` capability the preset roster reports, and the switch - * between opening a preset directory and answering its path as text. + * Whether `host.describe` reports that the Client may offer native path actions. * Absent, platform detection decides ({@link canOpenNativePath}). */ canOpenPath?: () => boolean diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4eab46efb1..5ac1fcc288 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2065,6 +2065,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-util-workspace-path': + specifier: workspace:^ + version: link:../../util/workspace-path '@types/react': specifier: ~18.3.1 version: 18.3.31 From 2ff3a0c09f76affec2bd68b04bc0f3bc6ef65344 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 21:49:39 +0800 Subject: [PATCH 105/130] fix(file-reference): remove unused zod dependency --- packages/context/file-reference/package.json | 3 --- pnpm-lock.yaml | 4 ---- 2 files changed, 7 deletions(-) diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index f1e45418b6..1713c8d341 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -49,8 +49,5 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^" - }, - "dependencies": { - "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ac1fcc288..55ffa061ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4022,10 +4022,6 @@ importers: version: link:../../core/tools packages/context/file-reference: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ From ea07f465acab3218aeccad11bbbe7b87684388f3 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:00:58 +0800 Subject: [PATCH 106/130] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 00810b4551..558d4a4200 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: aebb6883280d5edc48355e83936cb8793529fc2a -module-graph.zh.md: 43607e291b632d1df6cd00a59e182027dc548008 +module-graph.md: 7abbcb938f2b2a203523e569422ef4cace658fe8 +module-graph.zh.md: 862f24dfc374e8b783bf94cebe29a58d52d7a02b diff --git a/docs/module-graph.md b/docs/module-graph.md index aebb688328..7abbcb938f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1545,6 +1545,7 @@ flowchart TD pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools + pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller pkg_client_ui_commands --> pkg_client_locale @@ -1935,7 +1936,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 43607e291b..862f24dfc3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1547,6 +1547,7 @@ flowchart TD pkg_client_ui_chat --> pkg_settings pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools + pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller pkg_client_ui_commands --> pkg_client_locale @@ -1937,7 +1938,7 @@ flowchart TD | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-agent-team`](../packages/experimental/agent-team), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`settings`](../packages/settings/settings), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | From e5e4b027426fda61301a7de0ddfe93b93edb7800 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:59:47 +0800 Subject: [PATCH 107/130] feat(connection): register exact Fetch routes --- packages/client/connection/src/index.ts | 3 + packages/client/connection/src/rpc-host.ts | 57 ++++++++++++- packages/client/connection/src/rpc.ts | 25 ++++++ .../tests/fetch-routes.host.spec.ts | 80 +++++++++++++++++++ 4 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 packages/client/connection/tests/fetch-routes.host.spec.ts diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c7203f38d8..c1c1f4d933 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -13,6 +13,8 @@ import { BrowserAuth } from './browser-auth.ts' import { HostConnectionService } from './rpc-host.ts' export type { + ConnectionFetchMethod, + ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, ConnectionRpcEndpointMatcher, @@ -22,6 +24,7 @@ export type { ConnectionRpcResult, ConnectionTrustRequest, HostConnectionHandle, + HostConnectionFetch, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 4f1e78341b..92de25729f 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -17,6 +17,8 @@ import type { BrowserAuth } from './browser-auth.ts' import type { ConnectionIndexRequest, ConnectionIndexResponse, + ConnectionFetchRoute, + HostConnectionFetch, ConnectionRpcEndpointMatcher, ConnectionRpcHandler, ConnectionRpcResult, @@ -35,6 +37,11 @@ interface ConnectionRpcInterceptor { readonly fetchHandler: FetchHandler } +interface RegisteredFetchRoute { + readonly methods: ReadonlySet + readonly fetch: ConnectionFetchRoute['fetch'] +} + interface ConnectionServerResponse { readonly type: 'server-response' readonly rpcId: RpcIdType @@ -51,6 +58,7 @@ declare module '@deepseek-ai/cordis' { /** Host Connection service whose channel registrations belong to the caller fiber. */ export class HostConnectionService extends Service implements HostConnectionHandle { private readonly interceptors = new Map() + private readonly fetchRoutes = new Map() /** * Provide the Host half over the active HTTP server. @@ -76,6 +84,14 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + /** Exact Fetch-route registry scoped to the Context reading this service. */ + get fetch(): HostConnectionFetch { + const owner = this.ctx + return { + register: route => this.registerFetchRoute(owner, route), + } + } + /** Apply the configured Host/Origin fence, then browser authentication. */ requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 @@ -104,7 +120,10 @@ export class HostConnectionService extends Service implements HostConnectionHand ): FetchHandler { return { fetch: (request) => { - const endpoint = endpointFromPath(channel, new URL(request.url).pathname) + const pathname = new URL(request.url).pathname + const route = this.fetchRoutes.get(pathname) + if (route?.methods.has(request.method) === true) return route.fetch(request) + const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return fallback.fetch(request) @@ -114,6 +133,24 @@ export class HostConnectionService extends Service implements HostConnectionHand } } + private registerFetchRoute( + owner: Context, + route: ConnectionFetchRoute, + ): () => Promise { + assertFetchRoute(route) + const registered: RegisteredFetchRoute = { + methods: new Set(route.methods), + fetch: route.fetch, + } + return owner.effect(() => { + if (this.fetchRoutes.has(route.path)) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} is already registered`) + } + this.fetchRoutes.set(route.path, registered) + return () => { this.fetchRoutes.delete(route.path) } + }, `client-connection: ${route.path} Fetch route`) + } + private register( owner: Context, channel: string, @@ -246,3 +283,21 @@ function assertChannel(channel: string): void { throw new Error(`connection: invalid or reserved RPC channel ${JSON.stringify(channel)}`) } } + +function assertFetchRoute(route: ConnectionFetchRoute): void { + if (endpointFromPath(API_PATH, route.path) === undefined) { + throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`) + } + if (route.methods.length === 0) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} declares no methods`) + } + const methods = new Set(route.methods) + if (methods.size !== route.methods.length) { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`) + } + for (const method of methods) { + if (method !== 'GET' && method !== 'HEAD') { + throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} has unsupported method ${JSON.stringify(method)}`) + } + } +} diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 1f879963af..9cfb47ab1c 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -43,6 +43,29 @@ export type ConnectionRpcHandler = ( /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean +/** HTTP methods supported by exact Fetch routes on the shared API channel. */ +export type ConnectionFetchMethod = 'GET' | 'HEAD' + +/** One exact, transport-independent Fetch route owned by a Host feature. */ +export interface ConnectionFetchRoute { + /** Absolute path below `/api`; query parameters remain available on the request URL. */ + readonly path: string + /** Methods this route owns. Other methods continue through normal shared-channel dispatch. */ + readonly methods: readonly ConnectionFetchMethod[] + /** Handle one request after the physical carrier has applied its trust and authentication policy. */ + readonly fetch: (request: Request) => Promise +} + +/** Host registry for exact Fetch routes that cannot use JSON Remote invocation. */ +export interface HostConnectionFetch { + /** + * Register one exact route on the shared API channel. + * @param route - path, methods, and Fetch-shaped implementation. + * @returns asynchronous disposer removing this exact contribution. + */ + register(route: ConnectionFetchRoute): () => Promise +} + /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** @@ -74,6 +97,8 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + /** Exact Fetch routes for streaming or browser-native responses. */ + readonly fetch: HostConnectionFetch /** * Apply Connection's Host/Origin checks and browser authentication to diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts new file mode 100644 index 0000000000..7c83fed1b5 --- /dev/null +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -0,0 +1,80 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import type { BrowserAuth } from '../src/browser-auth.ts' +import { HostConnectionService } from '../src/rpc-host.ts' + +async function mounted(): Promise<{ + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + const fiber = ctx.plugin((pluginCtx) => { + new HostConnectionService(pluginCtx, [], {} as BrowserAuth) + }) + await fiber.await() + return { + connection: ctx.get('connection') as HostConnectionService, + dispose: () => fiber.dispose(), + } +} + +describe('Connection exact Fetch routes', () => { + it('dispatches owned methods before the transitional fallback', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const route = vi.fn(async (request: Request) => + Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) + const fallback = vi.fn(async () => new Response('fallback', { status: 418 })) + const dispose = connection.fetch.register({ + path: '/api/session.export', + methods: ['GET', 'HEAD'], + fetch: route, + }) + const shared = connection.createSharedFetchHandler('/api', { fetch: fallback }) + + const response = await shared.fetch(new Request( + 'http://host/api/session.export?sessionId=session-1', + )) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ query: 'session-1' }) + expect(route).toHaveBeenCalledOnce() + expect(fallback).not.toHaveBeenCalled() + + const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) + expect(post.status).toBe(418) + expect(fallback).toHaveBeenCalledOnce() + + await dispose() + const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) + expect(withdrawn.status).toBe(418) + expect(fallback).toHaveBeenCalledTimes(2) + await disposeFiber() + }) + + it('rejects invalid and duplicate registrations', async () => { + const { connection, dispose: disposeFiber } = await mounted() + const fetch = async (): Promise => new Response() + + expect(() => connection.fetch.register({ path: '/outside', methods: ['GET'], fetch })) + .toThrow('invalid exact Fetch route') + expect(() => connection.fetch.register({ path: '/api/session.export', methods: [], fetch })) + .toThrow('declares no methods') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['GET', 'GET'], fetch, + })).toThrow('repeats a method') + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['POST' as 'GET'], fetch, + })).toThrow('unsupported method') + + const dispose = connection.fetch.register({ + path: '/api/session.export', methods: ['GET'], fetch, + }) + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).toThrow('already registered') + await dispose() + expect(() => connection.fetch.register({ + path: '/api/session.export', methods: ['HEAD'], fetch, + })).not.toThrow() + await disposeFiber() + }) +}) From 17c03bbbcca004ba029efd259e8e34197aa4fc75 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:01:23 +0800 Subject: [PATCH 108/130] feat(session-export): own the download route --- .../session-log-export/README.i18n.yaml | 4 +- .../session-log-export/README.md | 20 +- .../session-log-export/README.zh.md | 20 +- .../session-log-export/package.json | 16 +- .../session-log-export/src/archive.ts | 457 ++++++++++++++++++ .../session-log-export/src/index.ts | 141 +++++- .../session-log-export/src/invariant.ts | 5 +- .../tests/command.client.spec.ts | 3 + .../tests/loader-composition.client.spec.ts | 3 + .../tests/route.host.spec.ts | 105 ++++ .../session-log-export/tsconfig.client.json | 28 ++ .../session-log-export/tsconfig.host.json | 23 + .../session-log-export/tsconfig.json | 23 +- .../session-log-export/tsdown.config.ts | 6 +- pnpm-lock.yaml | 19 + tsconfig.client.json | 2 +- tsconfig.host.json | 1 + 17 files changed, 832 insertions(+), 44 deletions(-) create mode 100644 packages/session-query/session-log-export/src/archive.ts create mode 100644 packages/session-query/session-log-export/tests/route.host.spec.ts create mode 100644 packages/session-query/session-log-export/tsconfig.client.json create mode 100644 packages/session-query/session-log-export/tsconfig.host.json diff --git a/packages/session-query/session-log-export/README.i18n.yaml b/packages/session-query/session-log-export/README.i18n.yaml index ed1b3b512d..c8f260886d 100644 --- a/packages/session-query/session-log-export/README.i18n.yaml +++ b/packages/session-query/session-log-export/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-log-export/README.md -README.md: 455d7a14d0dd8e20347bdd7f8c76f12b88425d26 -README.zh.md: 0c2a03b3460c499192784bddcac06a6400941d44 +README.md: a46bcdcd6fa6dda8997b3ed07f0ae789d8e3471d +README.zh.md: 348f6c09c8c5de4e8fe7f779b6d0fd4f662e8e34 diff --git a/packages/session-query/session-log-export/README.md b/packages/session-query/session-log-export/README.md index 455d7a14d0..a46bcdcd6f 100644 --- a/packages/session-query/session-log-export/README.md +++ b/packages/session-query/session-log-export/README.md @@ -1,5 +1,5 @@ --- -description: "Web Session-log export for users of the Web bundle: the Session Header download button and /export command, and what to expect from the download dialog." +description: "Web Session-log ZIP export: Host streaming, the authenticated download route, the Session Header action, and the /export command." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-session-log-export` gives the Web interface a way to download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. A small dialog reports preparation, download start, or failure, shared by the button and the command. The ZIP is built and streamed by `dsh-host-apiproxy`; this package adds only the browser-side button and command. The download is a browser download: the browser chooses the destination. Setup and usage come first; the implementation internals live in a collapsible developer section below. +`dsh-session-log-export` lets the Web interface download a session's full history: a `Session log` button in the Session Header and an `/export` slash command both hand the session tree — the session, its sub-sessions, and attachments — to the browser as a ZIP download. The package owns the Host archive stream, its authenticated Fetch route, and the browser controls and feedback. The browser chooses the download destination. Setup and usage come first; implementation details follow. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Use this package when the Web bundle should let users export a session log. It is mounted only by the Web bundle, beside the host API proxy, the command registry, and the conversation UI. The common path is: mount the plugin, then click `Session log` in the Session Header or type `/export` — the browser downloads `dsh-session-.zip`. +Use this package when the Web bundle should let users export a session log. It requires Connection, the command registry, Session query and persistence, and attachments. Mount the plugin, then click `Session log` in the Session Header or type `/export`; the browser downloads `dsh-session-.zip`. ### When to choose it @@ -38,7 +38,13 @@ Choose it for a Web deployment that needs user-facing session export with a visi name: '@deepseek-ai/dsh-session-log-export' ``` -The Web bundle mounts the package beside `dsh-host-apiproxy`, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. +The Web bundle mounts the package with Connection, `dsh-commands`, `dsh-client-ui-commands`, and `dsh-client-ui-conversation`. + +### Configuration + +| Field | Default | Meaning | +|---|---|---| +| `compressionLevel` | `6` | DEFLATE level from 0 through 9 for each ZIP entry. | ### Command contract @@ -67,13 +73,13 @@ This section explains how the package wires the export control and points at the ### Design split -The package has two halves. The host half ([`src/index.ts`](src/index.ts)) registers the `/export` command on `ctx.commands`; the browser half ([`src/client/index.ts`](src/client/index.ts)) provides a `SessionLogDownloadController`, contributes the Header button and shared modal to the `conversation.session.header.utilities` slot, and observes `command/executed` so a successful `/export` in the submitting browser starts the same download. Other tabs still render the durable command row without repeating the browser side effect. +The package has two halves. The Host half ([`src/index.ts`](src/index.ts)) registers the `/export` command and contributes the exact `GET`/`HEAD /api/session.export` Fetch route to Connection; [`src/archive.ts`](src/archive.ts) builds the bounded ZIP stream. The browser half ([`src/client/index.ts`](src/client/index.ts)) provides the shared download controller and UI, and observes `command/executed` so only the submitting browser starts a download. ### Download flow Both entry paths issue a `HEAD` preflight to `GET /api/session.export?...`, then hand the GET URL to the browser download manager without buffering the ZIP in JavaScript. One controller owns one in-flight download per session, collapses concurrent gestures into that operation, and cancels the preflight on plugin disposal. Modal state lives in a snapshot store keyed by session, so the button and the command share one dialog per session. -The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy/README.md): it flushes a live root session before `readRaw` and streams the ZIP; ZIP generation, raw JSONL/zstd reads, descendants, attachments, backpressure, and HTTP error semantics belong there. +The Host route is a feature-owned exact Fetch contribution. Connection applies its Host/Origin and browser-session checks and bridges the streaming `Response`; this package owns query validation, live-session flushes, raw artifact and attachment reads, ZIP generation, and HTTP status semantics.
    @@ -84,7 +90,7 @@ The host download endpoint is owned by [`dsh-host-apiproxy`](../../host/apiproxy Read these pages when the package-level contract is not enough. They move from the Web control to the host endpoint and the surrounding command and session surfaces. -- [dsh-host-apiproxy](../../host/apiproxy/README.md) — the host-streamed ZIP download endpoint this package drives. +- [dsh-client-connection](../../client/connection/README.md) — the authenticated Fetch-route carrier used by the Host endpoint. - [Commands subsystem reference](../../../docs/subsystems/commands.md) — the human-command registry the `/export` command registers on. - [dsh-client-ui-commands](../../client/ui-commands/README.md) — the browser command surface that renders and acknowledges `/export`. - [Session Query package map](../README.md) — the retrieval family this package belongs to. diff --git a/packages/session-query/session-log-export/README.zh.md b/packages/session-query/session-log-export/README.zh.md index 0c2a03b346..348f6c09c8 100644 --- a/packages/session-query/session-log-export/README.zh.md +++ b/packages/session-query/session-log-export/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向 Web bundle 用户的会话日志导出:Session Header 下载按钮与 /export 命令,以及下载弹窗的预期行为。" +description: "Web 会话日志 ZIP 导出:Host 流式传输、认证下载路由、Session Header 操作与 /export 命令。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。一个小弹窗报告准备中、开始下载或失败,按钮与命令共用该弹窗。ZIP 由 `dsh-host-apiproxy` 生成并流式传输;本包只提供浏览器侧的按钮与命令。下载是浏览器下载:目标位置由浏览器选择。设置与用法在前;实现内部细节放在下方可折叠的开发者章节中。 +`dsh-session-log-export` 让 Web 界面可以下载会话的完整历史:Session Header 中的 `Session log` 按钮与 `/export` 斜杠命令都会把会话树——会话本身、其子会话与附件——作为 ZIP 交给浏览器下载。本包拥有 Host 归档流、经过认证的 Fetch 路由以及浏览器控制和反馈。下载目标位置由浏览器选择。设置与用法在前,随后说明实现细节。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当 Web bundle 需要让用户导出会话日志时使用本包。它只由 Web bundle 挂载,与 Host API 代理、命令注册表和对话 UI 并列。常用路径是:挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`——浏览器下载 `dsh-session-.zip`。 +当 Web bundle 需要让用户导出会话日志时使用本包。它需要 Connection、命令注册表、Session 查询与持久化以及附件服务。挂载插件,然后点击 Session Header 中的 `Session log` 或输入 `/export`;浏览器会下载 `dsh-session-.zip`。 ### 何时选择 @@ -38,7 +38,13 @@ kind: "package-reference" name: '@deepseek-ai/dsh-session-log-export' ``` -Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。 +Web bundle 将本包与 Connection、`dsh-commands`、`dsh-client-ui-commands` 和 `dsh-client-ui-conversation` 一起挂载。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `compressionLevel` | `6` | 每个 ZIP 条目的 DEFLATE 级别,范围为 0 到 9。 | ### 命令约定 @@ -67,13 +73,13 @@ Web bundle 将本包与 `dsh-host-apiproxy`、`dsh-commands`、`dsh-client-ui-co ### 设计拆分 -本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))在 `ctx.commands` 上注册 `/export` 命令;浏览器半包([`src/client/index.ts`](src/client/index.ts))提供 `SessionLogDownloadController`,把 Header 按钮与共享弹窗贡献到 `conversation.session.header.utilities` slot,并观察 `command/executed`,使提交命令的浏览器在 `/export` 成功后启动同一下载。其他标签页仍渲染持久命令行,但不会重复浏览器副作用。 +本包有两个半包。Host 半包([`src/index.ts`](src/index.ts))注册 `/export` 命令,并向 Connection 贡献精确的 `GET`/`HEAD /api/session.export` Fetch 路由;[`src/archive.ts`](src/archive.ts) 构建有界 ZIP 流。浏览器半包([`src/client/index.ts`](src/client/index.ts))提供共享下载控制器和 UI,并观察 `command/executed`,因此只有提交命令的浏览器会启动下载。 ### 下载流程 两条入口都会对 `GET /api/session.export?...` 发出 `HEAD` 预检,然后把 GET URL 交给浏览器下载管理器,JavaScript 不缓冲 ZIP。一个控制器按会话持有一项进行中的下载,把并发操作折叠进该任务,并在插件释放时取消预检。弹窗状态存放在按会话键控的快照存储中,因此按钮与命令按会话共享一个弹窗。 -Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥有:它在 `readRaw` 前 flush 活动的根会话并流式传输 ZIP;ZIP 生成、原始 JSONL/zstd 读取、子会话、附件、背压与 HTTP 错误语义都属于那里。 +Host 路由是业务拥有的精确 Fetch contribution。Connection 应用 Host/Origin 与浏览器会话检查并桥接流式 `Response`;本包拥有查询校验、活动会话 flush、原始产物与附件读取、ZIP 生成和 HTTP 状态语义。
    @@ -84,7 +90,7 @@ Host 下载端点由 [`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 拥 当包级约定不够用时阅读以下页面。它们从 Web 控制逐步进入 Host 端点与周围的命令和会话表面。 -- [dsh-host-apiproxy](../../host/apiproxy/README.zh.md)——本包驱动的 Host 流式 ZIP 下载端点。 +- [dsh-client-connection](../../client/connection/README.zh.md)——Host 端点使用的认证 Fetch 路由载体。 - [命令子系统参考](../../../docs/subsystems/commands.zh.md)——`/export` 命令注册的用户命令注册表。 - [dsh-client-ui-commands](../../client/ui-commands/README.zh.md)——渲染并确认 `/export` 的浏览器命令表面。 - [会话查询包映射](../README.zh.md)——本包所属的检索能力家族。 diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 8970f18b9b..9d7cd557e4 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -21,20 +21,31 @@ "files": ["lib/index.js", "lib/invariant.js", "lib/client.js", "lib/types/**/*.d.ts"], "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" }, "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "fflate": "^0.8.2" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", @@ -46,6 +57,9 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/session-query/session-log-export/src/archive.ts b/packages/session-query/session-log-export/src/archive.ts new file mode 100644 index 0000000000..2f7d1fbd11 --- /dev/null +++ b/packages/session-query/session-log-export/src/archive.ts @@ -0,0 +1,457 @@ +/** + * Host-side session-log download: streams one ZIP archive whose files are the + * sessions' stored artifact text verbatim plus every referenced media object. + * The root artifact sits under its original base name (`session.jsonl`); each + * subagent descendant under `subagents//`; each image referenced + * by any included log under `media/.` (content-addressed, + * so one archive never duplicates a shared image). No manifest is written — + * every file is byte-identical to the backend's durable artifact or attachment + * store and self-describing through its own header line or media type. Before + * each live session's artifact read, the SessionStore flush barrier makes the + * current in-memory log durable; cold sessions need no barrier. Request abort + * and response-consumer cancellation share one producer signal and terminate + * the active compressor. + * Compression runs on the host with fflate's streaming Zip API, so the archive + * bytes are produced incrementally and the host never holds the whole archive + * in one buffer; production waits for consumer pull whenever the response queue + * reaches its byte high-water mark, so a slow consumer bounds accumulation to + * the fixed 64 KiB response queue plus one synchronous fflate push. + * @module + */ + +import { Zip, ZipDeflate } from 'fflate' +import type { Context } from '@deepseek-ai/cordis' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' +import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' +import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + +/** Balanced default used when Session export configuration omits a compression level. */ +export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6 + +/** The services a session-log export needs (the live-session store is optional). */ +export interface SessionLogExportDeps { + readonly sessionQuery: SessionQueryEngine | undefined + readonly sessionPersistence: SessionPersistence | undefined + readonly attachments: AttachmentStore | undefined + readonly sessions: SessionStore | undefined +} + +/** The export services narrowed to the mounted ones streaming actually reads. */ +export interface SessionLogExportReady { + readonly sessionQuery: SessionQueryEngine + readonly sessionPersistence: SessionPersistence + readonly attachments: AttachmentStore + readonly sessions: SessionStore | undefined +} + +/** + * Resolve the persistence, session-query, and attachment services a log export needs. + * @param ctx - the composed host context. + * @returns the export services (absent when the deployment does not mount them). + */ +export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { + return { + sessionQuery: ctx.get('sessionQuery'), + sessionPersistence: ctx.get('sessionPersistence'), + attachments: ctx.get('attachments'), + sessions: ctx.get('sessions'), + } +} + +/** + * Flush one currently live session through the store's authoritative durability + * barrier immediately before its raw artifact is read. A cold or absent id has + * no in-memory work to flush. + * @param deps - export services, including the optional live-session store. + * @param id - the session whose artifact is about to be read. + * @param signal - optional cancellation observed around the flush barrier. + */ +export async function flushLiveSessionLog( + deps: Pick, + id: SessionId, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + const sessions = deps.sessions + if (sessions === undefined) return + const session = sessions.get(id) + if (session === undefined) return + await sessions.flush(session) + signal?.throwIfAborted() +} + +/** One exported file: a stored artifact text or one referenced media object. */ +export type SessionLogZipEntry = + | { readonly path: string; readonly content: string } + | { readonly path: string; readonly data: Uint8Array } + +/** Zip extension for each accepted raster media type. */ +const MEDIA_TYPE_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +} + +/** + * The zip path for one media object: content-addressed by the opaque + * attachment id so shared images land once and the id in the log maps back to + * the archive entry without a manifest. + * @param ref - the durable reference from a session log. + * @returns the archive path. + */ +function mediaEntryPath(ref: ImageAttachmentRef): string { + return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` +} + +/** + * Collect every image reference inside one content array, descending into + * nested tool results the way the live attachment route does. + * @param content - an event content array (or nested tool-result content). + * @param refs - the dedupe map being filled (keyed by attachment id). + */ +function collectImageRefs(content: unknown, refs: Map): void { + if (!Array.isArray(content)) return + const pending: unknown[] = [] + for (const item of content) pending.push(item) + while (pending.length > 0) { + const value = pending.pop() + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + refs.set(String(ref.attachmentId), ref) + } + if (Array.isArray(block.content)) { + for (const item of block.content) pending.push(item) + } + } +} + +/** + * Collect every image reference one session event carries, across the same + * carriers the live attachment route scans (direct content, message content, + * inserted messages, and completed assistant chunk blocks). + * @param event - one parsed JSONL event object. + * @param refs - the dedupe map being filled (keyed by attachment id). + */ +function collectEventImageRefs(event: unknown, refs: Map): void { + const data = (event as { data?: unknown }).data + if (typeof data !== 'object' || data === null) return + const carrier = data as { + content?: unknown + message?: { content?: unknown } + inserted?: Array<{ content?: unknown }> + chunk?: { type?: unknown; block?: unknown } + } + collectImageRefs(carrier.content, refs) + if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) + if (carrier.inserted !== undefined) { + for (const message of carrier.inserted) collectImageRefs(message.content, refs) + } + if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs) +} + +/** + * Collect the distinct media references one stored artifact text names. + * Lines that fail to parse cannot reference media and are skipped (the + * artifact text itself is exported verbatim regardless). + * @param content - the stored artifact text. + * @returns the dedupe map keyed by attachment id. + */ +function imageRefsInArtifact(content: string): Map { + const refs = new Map() + for (const line of content.split('\n')) { + if (line === '') continue + let event: unknown + try { + event = JSON.parse(line) + } catch { + continue + } + collectEventImageRefs(event, refs) + } + return refs +} + +/** + * One safe zip path segment from an untrusted session id. Session ids are + * host-controlled, but the brand allows any non-empty string, so `../`, dot + * segments, and separator characters are neutralized before they can shape + * archive entries. Distinct ids may collapse onto one segment (id collision + * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). + * @param id - the raw session id. + * @returns a filesystem-safe single path segment. + */ +function safeSessionIdSegment(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/g, '_') +} + +/** + * The export archive filename for one root session. + * @param sessionId - the root session id (sanitized to one safe path segment). + * @returns the attachment filename for the session's export archive. + */ +export function sessionLogZipFilename(sessionId: string): string { + return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` +} + +/** + * Yield the export entries in zip order: the preloaded root artifact first, + * then every subagent descendant in lineage order (each flushed when live, + * read from the persistence backend right before it is yielded, and dropped + * after the consumer moves on), then every distinct media object referenced by any of + * the included logs (read and verified from the attachment store, one archive + * entry per attachment id). The host holds at most one descendant's artifact + * text and one media object at a time beyond the root. + * @param deps - the mounted export services (the caller answered 500 before this runs). + * @param root - the already-read root artifact (read by the caller so the + * missing-session path can answer cleanly before streaming starts). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads. + * @returns the export entries in zip order. + */ +export async function* sessionLogZipEntries( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): AsyncGenerator { + const media = new Map() + const rememberMedia = (content: string): void => { + for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) + } + rememberMedia(root.content) + yield { path: root.filename, content: root.content } + if (includeDescendants) { + const seen = new Set([sessionId]) + const collect = async function* ( + nodes: readonly SessionLineageNode[], + ): AsyncGenerator { + for (const node of nodes) { + signal?.throwIfAborted() + const id = node.session.header.id + if (seen.has(id)) continue + seen.add(id) + await flushLiveSessionLog(deps, id, signal) + const raw = await deps.sessionPersistence.readRaw(id, signal) + signal?.throwIfAborted() + if (raw === undefined) { + throw new Error(`subagent "${id}" has no stored log artifact`) + } + rememberMedia(raw.content) + yield { + path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, + content: raw.content, + } + yield* collect(node.descendants) + } + } + const lineage = await deps.sessionQuery.traceSession(sessionId, signal) + signal?.throwIfAborted() + yield* collect(lineage.descendants) + } + for (const ref of media.values()) { + signal?.throwIfAborted() + const stored = await deps.attachments.readImage(ref, signal) + signal?.throwIfAborted() + yield { path: mediaEntryPath(ref), data: stored.data } + } +} + +/** How many code units of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_UNITS = 1 << 16 + +/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */ +const PUSH_CHUNK_BYTES = 1 << 16 + +/** Byte capacity retained by the response stream before ZIP production waits for pull. */ +const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16 + +/** One producer waiter released only when ReadableStream pull restores capacity. */ +class ResponseCapacityGate { + private releasePending: (() => void) | undefined + + /** + * Wait until the response queue has positive byte capacity or cancellation wins. + * @param controller - response controller whose desired size owns capacity. + * @param signal - combined request/consumer cancellation. + */ + async wait( + controller: ReadableStreamDefaultController, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted() + if (controller.desiredSize === null || controller.desiredSize > 0) return + await new Promise((resolve) => { + const release = (): void => { + this.releasePending = undefined + signal.removeEventListener('abort', release) + resolve() + } + this.releasePending = release + signal.addEventListener('abort', release, { once: true }) + }) + signal.throwIfAborted() + } + + /** Release the current producer waiter after a consumer pull. */ + pulled(): void { + this.releasePending?.() + } +} + +/** + * Push one media object's bytes into a deflate stream in bounded chunks, + * waiting for consumer capacity between chunks like the artifact path does. + * @param deflate - the zip entry's deflate stream. + * @param data - the stored image bytes. + * @param controller - response queue controller. + * @param capacity - pull-driven response-capacity gate. + * @param signal - cancellation; throws when aborted. + */ +async function pushBinaryChunks( + deflate: ZipDeflate, + data: Uint8Array, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + let offset = 0 + do { + signal.throwIfAborted() + const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength) + const finalChunk = end >= data.byteLength + deflate.push(data.subarray(offset, end), finalChunk) + offset = end + await capacity.wait(controller, signal) + } while (offset < data.byteLength) +} + +/** + * Push one artifact's text into a deflate stream in bounded chunks, never + * splitting a surrogate pair across a chunk boundary (a lone high surrogate + * re-encodes as U+FFFD and would silently corrupt the exported artifact). + * @param deflate - the zip entry's deflate stream. + * @param content - the artifact text verbatim. + * @param controller - response queue controller. + * @param capacity - pull-driven response-capacity gate. + * @param signal - cancellation; throws when aborted. + */ +async function pushArtifactChunks( + deflate: ZipDeflate, + content: string, + controller: ReadableStreamDefaultController, + capacity: ResponseCapacityGate, + signal: AbortSignal, +): Promise { + const encoder = new TextEncoder() + let offset = 0 + let finalChunk: boolean + do { + signal.throwIfAborted() + let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) + if (end < content.length && end - offset > 1) { + // Back off one code unit when the boundary lands inside a surrogate + // pair: the pair then starts the next chunk whole. + const last = content.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + } + finalChunk = end >= content.length + deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) + offset = end + await capacity.wait(controller, signal) + } while (!finalChunk) +} + +/** + * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is + * read and validated by the caller before this is called (missing root or + * missing services answer cleanly before any byte is produced); each entry is + * then encoded and deflated in bounded chunks as it is produced, so the + * archive bytes arrive incrementally. A descendant that fails to read errors + * the stream (fail-loud, never silent under-export). + * @param deps - the mounted export services (the caller answered 500 before this runs). + * @param root - the already-read root artifact (first zip entry). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry. + * @param signal - request cancellation combined with response-consumer cancellation. + * @returns the zip byte stream. + */ +export function streamSessionLogZip( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + compressionLevel: SessionLogCompressionLevel, + signal: AbortSignal, +): ReadableStream { + const consumerAbort = new AbortController() + const producerSignal = AbortSignal.any([signal, consumerAbort.signal]) + let zip: Zip | undefined + let zipTerminated = false + const capacity = new ResponseCapacityGate() + const terminateZip = (): void => { + if (zip === undefined || zipTerminated) return + zipTerminated = true + zip.terminate() + } + return new ReadableStream({ + start(controller) { + // fflate invokes the callback synchronously per compressed chunk, so a + // single push can enqueue ahead of a slow consumer; the capacity gate + // waits for pull between pushes once the byte queue is full, bounding + // accumulation to the queue high-water mark plus one synchronous push. + const archive = new Zip((error, data, final) => { + /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ + if (error) { + controller.error(error) + return + } + /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ + if (data.byteLength > 0) controller.enqueue(data) + if (final) controller.close() + }) + zip = archive + void (async () => { + try { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { + const deflate = new ZipDeflate(entry.path, { level: compressionLevel }) + archive.add(deflate) + if ('content' in entry) { + await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) + } else { + await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) + } + } + archive.end() + } catch (error) { + // A mid-stream failure (missing descendant, cancellation, read + // error) must fail the download rather than ship a truncated archive. + /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ + terminateZip() + controller.error(error instanceof Error ? error : new Error(String(error))) + } + })() + }, + pull() { + capacity.pulled() + }, + cancel(reason) { + consumerAbort.abort( + reason instanceof Error ? reason : new Error('session log export stream cancelled'), + ) + terminateZip() + }, + }, { + highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES, + size: chunk => chunk.byteLength, + }) +} diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index d26ffa9e77..4be729d3a0 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -1,10 +1,63 @@ -/** Web Session-log download command over the host endpoint owned by ApiProxy. */ +/** Session-log download command and Host-owned streaming route. */ import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-attachment' import type { CommandResult } from '@deepseek-ai/dsh-commands' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { + DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + flushLiveSessionLog, + sessionLogExportDeps, + sessionLogZipFilename, + streamSessionLogZip, + type SessionLogCompressionLevel, + type SessionLogExportReady, +} from './archive.ts' + +export { + DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + flushLiveSessionLog, + sessionLogExportDeps, + sessionLogZipEntries, + sessionLogZipFilename, + streamSessionLogZip, +} from './archive.ts' +export type { + SessionLogCompressionLevel, + SessionLogExportDeps, + SessionLogExportReady, + SessionLogZipEntry, +} from './archive.ts' export const name = 'session-log-download' -export const inject = ['commands'] +export const inject = ['commands', 'connection'] + +/** Stable browser download path retained across the transport migration. */ +export const SESSION_LOG_EXPORT_PATH = '/api/session.export' + +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Validate Session-log archive configuration. */ +export const Config: Schema = Schema.object({ + compressionLevel: Schema.number().step(1).min(0).max(9) + .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as Schema, +}) + +interface SessionLogConnection { + readonly fetch: { + register(route: { + readonly path: string + readonly methods: readonly ('GET' | 'HEAD')[] + readonly fetch: (request: Request) => Promise + }): () => Promise + } +} const REQUESTED: CommandResult = { kind: 'success', @@ -12,10 +65,11 @@ const REQUESTED: CommandResult = { } /** - * Register the Web-only `/export` command that the browser download plugin observes. + * Register the Web-only `/export` command and authenticated ZIP download route. * @param ctx - Host context carrying the human-command registry. + * @param config - resolved compression policy. */ -export function apply(ctx: Context): void { +export function apply(ctx: Context, config: Config = {}): void { ctx.effect(() => ctx.commands.register({ name: 'export', description: 'Download this Session log as a ZIP archive', @@ -23,4 +77,83 @@ export function apply(ctx: Context): void { ? REQUESTED : { kind: 'error', text: 'The Web /export command does not accept a path.' }), }), 'session-log-download: command') + connectionOf(ctx).fetch.register({ + path: SESSION_LOG_EXPORT_PATH, + methods: ['GET', 'HEAD'], + fetch: request => sessionLogExportResponse( + ctx, + request, + config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + ), + }) +} + +function connectionOf(ctx: Context): SessionLogConnection { + return Reflect.get(ctx, 'connection') as SessionLogConnection +} + +async function sessionLogExportResponse( + ctx: Context, + request: Request, + compressionLevel: SessionLogCompressionLevel, +): Promise { + const url = new URL(request.url) + const query = Object.fromEntries(url.searchParams) + const sessionIdValue = query['sessionId'] + const descendantsValue = query['includeDescendants'] + if (sessionIdValue === undefined || sessionIdValue.length === 0 + || (descendantsValue !== undefined && descendantsValue !== 'true' && descendantsValue !== 'false')) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + const sessionId = SessionId(sessionIdValue) + const deps = sessionLogExportDeps(ctx) + if (deps.sessionQuery === undefined + || deps.sessionPersistence === undefined + || deps.attachments === undefined) { + return new Response( + 'session log export is unavailable: missing session-query, session-persistence, or attachments service', + { status: 500 }, + ) + } + if (!deps.sessionPersistence.supportsRawArtifacts) { + return new Response( + 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', + { status: 501 }, + ) + } + const ready: SessionLogExportReady = { + sessionQuery: deps.sessionQuery, + sessionPersistence: deps.sessionPersistence, + attachments: deps.attachments, + sessions: deps.sessions, + } + let root: SessionRawArtifact | undefined + try { + await flushLiveSessionLog(deps, sessionId, request.signal) + root = await deps.sessionPersistence.readRaw(sessionId, request.signal) + request.signal.throwIfAborted() + } catch { + request.signal.throwIfAborted() + return new Response('session log export failed to prepare the stored artifact', { status: 500 }) + } + if (root === undefined) return new Response('session not found', { status: 404 }) + const response = new Response( + streamSessionLogZip( + ready, + root, + sessionId, + descendantsValue === 'true', + compressionLevel, + request.signal, + ), + { + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${sessionLogZipFilename(sessionId)}"`, + }, + }, + ) + if (request.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) } diff --git a/packages/session-query/session-log-export/src/invariant.ts b/packages/session-query/session-log-export/src/invariant.ts index d38ea70699..0af82953e1 100644 --- a/packages/session-query/session-log-export/src/invariant.ts +++ b/packages/session-query/session-log-export/src/invariant.ts @@ -9,7 +9,10 @@ const PACKAGE_NAME = '@deepseek-ai/dsh-session-log-export' export const name = 'session-export-invariant' export const inject = ['invariants'] -/** No runtime invariant: the command registry owns lifecycle pairing and ApiProxy owns ZIP integrity. */ +/** + * No runtime invariant: Connection and the command registry own both + * registrations, while each export reads authoritative Session services. + */ const install: InvariantInstaller = () => {} /** diff --git a/packages/session-query/session-log-export/tests/command.client.spec.ts b/packages/session-query/session-log-export/tests/command.client.spec.ts index 3fa60909f1..16936afbe6 100644 --- a/packages/session-query/session-log-export/tests/command.client.spec.ts +++ b/packages/session-query/session-log-export/tests/command.client.spec.ts @@ -13,6 +13,9 @@ describe('/export Web download command', () => { return () => { descriptor = undefined } }, } as never) + ctx.provide('connection', { + fetch: { register: () => () => Promise.resolve() }, + } as never) const fiber = await ctx.plugin(SessionLogDownload) expect(descriptor).toMatchObject({ diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts index 2a993f52ef..a1e58b11ae 100644 --- a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts +++ b/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts @@ -34,6 +34,9 @@ describe('session-log-download real Loader composition', () => { context = new Context() context.baseUrl = pathToFileURL(root).href + '/' + context.provide('connection', { + fetch: { register: () => () => Promise.resolve() }, + } as never) await context.plugin(Loader) context.loader.builtins.include = Include const modules = new Map([ diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts new file mode 100644 index 0000000000..213ad852a6 --- /dev/null +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -0,0 +1,105 @@ +import { Context } from '@deepseek-ai/cordis' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { strFromU8, unzipSync } from 'fflate' +import { describe, expect, it } from 'vitest' +import { + Config, + SESSION_LOG_EXPORT_PATH, + apply, + inject, +} from '../src/index.ts' + +const sid = (value: string): SessionId => value as SessionId + +function artifact(id: string): SessionRawArtifact { + const header: SessionHeader = { + version: 0, + id: sid(id), + createdAt: 1, + cwd: '/workspace', + delegationDepth: 0, + } + return { + meta: header, + filename: 'session.jsonl', + content: `${JSON.stringify({ type: 'session', ...header })}\n`, + } +} + +async function mounted(withServices: boolean): Promise<{ + readonly connection: HostConnectionService + readonly dispose: () => Promise +}> { + const ctx = new Context() + ctx.provide('commands', { register: () => () => {} } as never) + if (withServices) { + ctx.provide('sessionQuery', { + traceSession: async () => ({ descendants: [] }), + } as never) + ctx.provide('sessionPersistence', { + supportsRawArtifacts: true, + readRaw: async (id: SessionId) => artifact(String(id)), + } as never) + ctx.provide('attachments', { + readImage: async () => { throw new Error('fixture has no images') }, + } as never) + } + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { connection, dispose: () => fiber.dispose() } +} + +describe('Session log export Fetch route', () => { + it('registers one GET/HEAD route and removes it with the plugin fiber', async () => { + const { connection, dispose } = await mounted(true) + const fallback = { fetch: async () => new Response('fallback', { status: 418 }) } + const shared = connection.createSharedFetchHandler('/api', fallback) + + const response = await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, + )) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + const files = unzipSync(new Uint8Array(await response.arrayBuffer())) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toContain('"id":"session-1"') + + const head = await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, { method: 'HEAD' }, + )) + expect(head.status).toBe(200) + expect(head.body).toBeNull() + + await dispose() + expect((await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, + ))).status).toBe(418) + }) + + it('validates the query before reporting missing export services', async () => { + const { connection, dispose } = await mounted(false) + const shared = connection.createSharedFetchHandler('/api', { + fetch: async () => new Response('fallback', { status: 418 }), + }) + expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400) + expect((await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`, + ))).status).toBe(400) + expect((await shared.fetch(new Request( + `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, + ))).status).toBe(500) + await dispose() + }) + + it('validates the compression level', () => { + expect(Config({})).toEqual({ compressionLevel: 6 }) + expect(Config({ compressionLevel: 0 })).toEqual({ compressionLevel: 0 }) + expect(Config({ compressionLevel: 9 })).toEqual({ compressionLevel: 9 }) + for (const compressionLevel of [-1, 10, 1.5]) { + expect(() => Config({ compressionLevel } as never)).toThrow() + } + }) +}) diff --git a/packages/session-query/session-log-export/tsconfig.client.json b/packages/session-query/session-log-export/tsconfig.client.json new file mode 100644 index 0000000000..6e3e746c4c --- /dev/null +++ b/packages/session-query/session-log-export/tsconfig.client.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/Dialog.tsx", + "src/client/HeaderAction.tsx", + "src/client/controller.ts", + "src/client/index.ts", + "src/client/locales.ts", + "src/css-modules.d.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../client/locale" }, + { "path": "../../client/store" }, + { "path": "../../client/ui-commands" }, + { "path": "../../client/ui-conversation" }, + { "path": "../../client/ui-primitives" }, + { "path": "../../client/ui-renderer" }, + { "path": "../../client/ui-session" }, + { "path": "../../client/ui-slots" }, + { "path": "../../core/session" } + ] +} diff --git a/packages/session-query/session-log-export/tsconfig.host.json b/packages/session-query/session-log-export/tsconfig.host.json new file mode 100644 index 0000000000..982a1360cb --- /dev/null +++ b/packages/session-query/session-log-export/tsconfig.host.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/archive.ts", + "src/index.ts", + "src/invariant.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../attachment/attachment" }, + { "path": "../../core/session" }, + { "path": "../../interaction/commands" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../session/session-persistence" }, + { "path": "../session-query" } + ] +} diff --git a/packages/session-query/session-log-export/tsconfig.json b/packages/session-query/session-log-export/tsconfig.json index 46ad790e94..2a0b0e33f7 100644 --- a/packages/session-query/session-log-export/tsconfig.json +++ b/packages/session-query/session-log-export/tsconfig.json @@ -1,24 +1,7 @@ { - "extends": "../../../tsconfig.base.client.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], + "files": [], "references": [ - { "path": "../../../vendor/cordis" }, - { "path": "../../interaction/commands" }, - { "path": "../../client/locale" }, - { "path": "../../client/store" }, - { "path": "../../core/session" }, - { "path": "../../client/ui-commands" }, - { "path": "../../client/ui-conversation" }, - { "path": "../../client/ui-primitives" }, - { "path": "../../client/ui-renderer" }, - { "path": "../../client/ui-session" }, - { "path": "../../client/ui-slots" }, - { "path": "../../runtime-diagnostics/invariants" } + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } ] } diff --git a/packages/session-query/session-log-export/tsdown.config.ts b/packages/session-query/session-log-export/tsdown.config.ts index dba3a1ab45..441876ff7b 100644 --- a/packages/session-query/session-log-export/tsdown.config.ts +++ b/packages/session-query/session-log-export/tsdown.config.ts @@ -1,3 +1,7 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-session-log-export', ['lib/types/index.js', 'lib/types/invariant.js']) +export default clientBundle( + '@deepseek-ai/dsh-session-log-export', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55ffa061ae..a021582cd9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7047,6 +7047,13 @@ importers: version: link:../../subagent/subagent packages/session-query/session-log-export: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + fflate: + specifier: ^0.8.2 + version: 0.8.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -7057,6 +7064,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale @@ -7090,6 +7103,12 @@ importers: '@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-query': + specifier: workspace:^ + version: link:../session-query '@types/react': specifier: ~18.3.1 version: 18.3.31 diff --git a/tsconfig.client.json b/tsconfig.client.json index bd8d98e4b8..8b70f8d2c7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -90,7 +90,7 @@ { "path": "./packages/client/ui-settings-plugins" }, { "path": "./packages/client/ui-user-questions" }, { "path": "./packages/client/ui-trajectory" }, - { "path": "./packages/session-query/session-log-export" }, + { "path": "./packages/session-query/session-log-export/tsconfig.client.json" }, { "path": "./packages/client/ui-theme" }, { "path": "./packages/client/ui-settings" }, { "path": "./packages/client/ui-settings-general" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d4cd66e776..a707fa8df7 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -160,6 +160,7 @@ { "path": "./packages/session/session-stats" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/session-query/session-log-export/tsconfig.host.json" }, { "path": "./packages/settings/settings" }, { "path": "./packages/settings/settings-file" }, { "path": "./packages/credentials/credentials" }, From e036aae7c00b4bf4d36f9059488d4fefc2e48fc9 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:11:00 +0800 Subject: [PATCH 109/130] refactor(connection): carry Host facts with generations --- .../api/gateway/src/client/remote-events.ts | 27 +++++--- .../api/gateway/src/client/remote-stream.ts | 10 +-- packages/api/gateway/src/index.ts | 13 +++- packages/api/gateway/src/stream-protocol.ts | 8 +++ packages/api/gateway/src/types.ts | 7 +- .../tests/control-retry.client.spec.ts | 28 ++++---- .../gateway/tests/gateway-stream.host.spec.ts | 39 +++++------ .../api/gateway/tests/gateway.client.spec.ts | 9 ++- .../api/gateway/tests/gateway.host.spec.ts | 7 +- .../tests/journal-stream.client.spec.ts | 6 +- packages/api/remotes/src/index.ts | 3 +- .../remotes/tests/remote-events.host.spec.ts | 20 +++++- .../session-controller/src/client/index.ts | 2 +- .../tests/client-apply.client.spec.ts | 18 +++++ .../tests/fake-api.client.ts | 6 +- .../tests/transport.client.spec.ts | 6 +- .../tests/transport.client.spec.ts | 9 ++- .../connection/src/client/connection.ts | 49 +++++++++----- .../client/connection/src/client/fixture.ts | 3 +- .../client/connection/src/client/index.ts | 55 ++++++++++++++-- .../tests/client-apply.client.spec.ts | 2 +- .../tests/connection.client.spec.ts | 2 +- .../connection/tests/fake-api.client.ts | 10 ++- .../tests/generation.client.spec.ts | 65 +++++++++++++++++++ 24 files changed, 298 insertions(+), 106 deletions(-) create mode 100644 packages/client/connection/tests/generation.client.spec.ts diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts index 341c1f3f4d..9552478162 100644 --- a/packages/api/gateway/src/client/remote-events.ts +++ b/packages/api/gateway/src/client/remote-events.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ConnectionGenerationSource, + ConnectionHostInfo, ConnectionHandle, } from '@deepseek-ai/dsh-client-connection/client' import type { @@ -118,7 +119,10 @@ export class ClientRemoteEvents { } /** Run one Connection generation over the forwarded-event logical stream. */ - private async pumpEvents(signal: AbortSignal, ready: () => void): Promise { + private async pumpEvents( + signal: AbortSignal, + ready: (host: ConnectionHostInfo) => void, + ): Promise { let clientId: RemoteEventClientId | undefined const failed = new AbortController() const generationSignal = AbortSignal.any([signal, failed.signal]) @@ -134,8 +138,9 @@ export class ClientRemoteEvents { try { for await (const value of source) { if (clientId === undefined) { - clientId = parseRemoteEventReady(value) - ready() + const opening = parseRemoteEventReady(value) + clientId = opening.clientId + ready(opening.host) continue } const frame = parseRemoteEventFrame(value) @@ -251,15 +256,21 @@ export class ClientRemoteEvents { } } -/** Validate and return the Client identity from one generation's opening item. */ -function parseRemoteEventReady(value: unknown): RemoteEventClientId { +/** Validate and return one generation's Client identity and Host facts. */ +function parseRemoteEventReady(value: unknown): { + readonly clientId: RemoteEventClientId + readonly host: ConnectionHostInfo +} { if (!isRemoteEventRecord(value) - || !hasExactRemoteEventKeys(value, ['type', 'clientId']) + || !hasExactRemoteEventKeys(value, ['type', 'clientId', 'host']) || value.type !== 'ready' - || !isRemoteEventClientId(value.clientId)) { + || !isRemoteEventClientId(value.clientId) + || !isRemoteEventRecord(value.host) + || !hasExactRemoteEventKeys(value.host, ['home']) + || typeof value.host.home !== 'string') { throw new TypeError('client api: forwarded Remote event stream did not begin with ready') } - return value.clientId + return { clientId: value.clientId, host: { home: value.host.home } } } /** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 799a371ef5..71f1f546ae 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -48,7 +48,7 @@ export class RemoteStream implements AsyncIterable> * @param options - domain stream opener, end classification, and diagnostics. */ constructor( - private readonly connection: Pick, + private readonly connection: Pick, private readonly options: RemoteStreamOptions, ) {} @@ -157,13 +157,13 @@ export class RemoteStream implements AsyncIterable> } async function waitForRemoteStreamRetry( - connection: Pick, + connection: Pick, error: RemoteStreamCarrierError, attempt: number, signal: AbortSignal, ): Promise { signal.throwIfAborted() - if (connection.hostDescription.getSnapshot() !== undefined) { + if (connection.generation.getSnapshot() !== undefined) { if (attempt === 1) return throw error } @@ -181,12 +181,12 @@ async function waitForRemoteStreamRetry( else reject(failure) } const inspect = (): void => { - if (connection.hostDescription.getSnapshot() !== undefined) finish() + if (connection.generation.getSnapshot() !== undefined) finish() } const aborted = (): void => { finish(new Error('Remote stream retry aborted', { cause: signal.reason })) } - const dispose = connection.hostDescription.subscribe(inspect) + const dispose = connection.generation.subscribe(inspect) subscription.dispose = dispose if (subscription.finished) dispose() signal.addEventListener('abort', aborted, { once: true }) diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index ec4eb1eaef..1d754b05a7 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -48,6 +48,7 @@ import { type RemoteEventCancellationFrame, type RemoteEventClientId, type RemoteEventEmitFrame, + type RemoteEventHostInfo, type RemoteEventId, type RemoteEventInvocationFrame, type RemoteEventReadyFrame, @@ -66,6 +67,7 @@ export type { TypertRemoteEventOutcome, TypertRemoteEventSource, } from './types.ts' +export type { RemoteEventHostInfo } from './stream-protocol.ts' interface GatewayErrorOptions { readonly cause?: unknown @@ -88,6 +90,7 @@ interface PreparedInvocation { interface RegisteredRemoteEventSource { readonly lifetime: AbortController readonly done: Promise + readonly host: RemoteEventHostInfo } interface RemoteEventClient { @@ -233,9 +236,13 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise { + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise { if (this.remoteEvents !== undefined) { throw new Error('typert gateway: forwarded Remote event source is already registered') } @@ -247,7 +254,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.remoteEvents = undefined lifetime.abort(error) }) - const registration: RegisteredRemoteEventSource = { lifetime, done } + const registration: RegisteredRemoteEventSource = { lifetime, done, host: { home: host.home } } this.remoteEvents = registration return async () => { if (this.remoteEvents === registration) { @@ -417,7 +424,7 @@ export class TypertGatewayService extends Service implements TypertGateway { this.remoteEventClients.set(clientId, client) for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) try { - yield { ...REMOTE_EVENT_STREAM_READY, clientId } + yield { ...REMOTE_EVENT_STREAM_READY, clientId, host: registration.host } yield* client.queue.iterate(lifetime) } finally { this.removeRemoteEventClient(client) diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts index 2c6e8ebf70..142598863e 100644 --- a/packages/api/gateway/src/stream-protocol.ts +++ b/packages/api/gateway/src/stream-protocol.ts @@ -23,10 +23,18 @@ export type RemoteEventClientId = Branded<'RemoteEventClientId'> /** Opaque correlation id for one pending Host-to-Client Remote Event. */ export type RemoteEventId = Branded<'RemoteEventId'> +/** Stable Host facts published with every established Client event generation. */ +export interface RemoteEventHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string +} + /** Opening item that binds later HTTP results to this active event stream. */ export interface RemoteEventReadyFrame { readonly type: 'ready' readonly clientId: RemoteEventClientId + /** Stable Host facts attached to this connection generation. */ + readonly host: RemoteEventHostInfo } /** Opaque Agent identity carried by one scoped Remote Event. */ diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index b41f35e905..9b4475cb9a 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -4,6 +4,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import type { RemoteEventHostInfo } from './stream-protocol.ts' /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { @@ -124,9 +125,13 @@ export interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index a278cc6acd..234251babb 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -5,23 +5,17 @@ import { RemoteStream, } from '../src/client/index.ts' -const DESCRIPTION = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION = { id: 1, host: { home: '/home/fixture' } } function hostSource(initiallyAvailable: boolean): { - connection: Pick + connection: Pick publish(available: boolean): void } { - let current = initiallyAvailable ? DESCRIPTION : undefined + let current = initiallyAvailable ? GENERATION : undefined const listeners = new Set<() => void>() return { connection: { - hostDescription: { + generation: { getSnapshot: () => current, subscribe: (listener) => { listeners.add(listener) @@ -30,7 +24,7 @@ function hostSource(initiallyAvailable: boolean): { }, }, publish: (available) => { - current = available ? DESCRIPTION : undefined + current = available ? GENERATION : undefined for (const listener of listeners) listener() }, } @@ -67,7 +61,7 @@ function scripted(generations: Generation[], opened?: () => void) { } function supervisor( - connection: Pick, + connection: Pick, generations: Generation[], carrierFailed?: (error: RemoteStreamCarrierError) => void, ): RemoteStream { @@ -122,8 +116,8 @@ describe('RemoteStream', () => { let listener: (() => void) | undefined const subscribed = Promise.withResolvers() const connection = { - hostDescription: { - getSnapshot: () => available ? DESCRIPTION : undefined, + generation: { + getSnapshot: () => available ? GENERATION : undefined, subscribe: (value: () => void) => { listener = value subscribed.resolve(undefined) @@ -177,8 +171,8 @@ describe('RemoteStream', () => { let reads = 0 let disposed = 0 const connection = { - hostDescription: { - getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION, + generation: { + getSnapshot: () => reads++ === 0 ? undefined : GENERATION, subscribe: (listener: () => void) => { listener() return () => { disposed++ } @@ -261,7 +255,7 @@ describe('RemoteStream', () => { const holder: { stream?: RemoteStream } = {} let subscriptions = 0 const connection = { - hostDescription: { + generation: { getSnapshot: () => undefined, subscribe: () => { subscriptions++ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index debb6763d5..c769192a4b 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -36,6 +36,7 @@ vi.mock('node:crypto', async (importOriginal) => { const randomUuid = vi.mocked(randomUUID) const browserCookies = new WeakMap() +const REMOTE_HOST = { home: '/home/fixture' } as const type AgentWireId = TypertContextWire const agentId = (value: string): AgentWireId => value as AgentWireId @@ -386,8 +387,8 @@ describe('Typert Remote streams', () => { } })() } - const unregister = ctx.typertGateway.registerRemoteEvents(source) - expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) + expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) }) .toThrow('forwarded Remote event source is already registered') const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { @@ -402,7 +403,7 @@ describe('Typert Remote streams', () => { const eventFrames = frames.filter(frame => frame.streamId === 'events') expect(eventFrames).toHaveLength(1) expect(eventFrames[0]).toMatchObject({ - type: 'item', streamId: 'events', value: { type: 'ready' }, + type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST }, }) expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') }) @@ -411,7 +412,7 @@ describe('Typert Remote streams', () => { const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2) expect(eventFrames).toHaveLength(2) expect(eventFrames[0]).toMatchObject({ - type: 'item', streamId: 'events', value: { type: 'ready' }, + type: 'item', streamId: 'events', value: { type: 'ready', host: REMOTE_HOST }, }) expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') expect(eventFrames[1]).toEqual({ @@ -429,9 +430,9 @@ describe('Typert Remote streams', () => { expect(frames).toContainEqual({ type: 'end', streamId: 'events' }) }) - const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source) + const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) await unregister() - expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + expect(() => { ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) }) .toThrow('forwarded Remote event source is already registered') await unregisterReplacement() socket.close() @@ -446,7 +447,7 @@ describe('Typert Remote streams', () => { await publish.promise yield pending.dispatch })() - const unregister = ctx.typertGateway.registerRemoteEvents(source) + const unregister = ctx.typertGateway.registerRemoteEvents(source, REMOTE_HOST) const rejected = expect(pending.outcome).rejects.toThrow( 'forwarded Remote event source was removed', ) @@ -479,7 +480,7 @@ describe('Typert Remote streams', () => { else signal.addEventListener('abort', () => { resolve() }, { once: true }) }) throw new Error('fixture source rejected during removal') - })()) + })(), REMOTE_HOST) const client = await openEventClient(ctx, 'events-removal') await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) @@ -496,7 +497,7 @@ describe('Typert Remote streams', () => { it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) for (const event of [42, ''] as const) { const invalidName = pendingInvocation(ctx) @@ -579,7 +580,7 @@ describe('Typert Remote streams', () => { return (async function* () { yield frame as unknown as TypertRemoteEventDispatch })() - }) + }, REMOTE_HOST) await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) const reason: unknown = sourceSignal?.reason if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error') @@ -591,7 +592,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event id before publishing the second waterfall', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -626,7 +627,7 @@ describe('Typert Remote streams', () => { it('retries a colliding Remote event Client id before opening the second generation', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) @@ -645,7 +646,7 @@ describe('Typert Remote streams', () => { it('fans one scoped waterfall out and accepts the first Client result', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -699,7 +700,7 @@ describe('Typert Remote streams', () => { it('rejects the Host waterfall with the first Client listener rejection', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -739,7 +740,7 @@ describe('Typert Remote streams', () => { it('delegates to the Host only after every active Client returns next', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -772,7 +773,7 @@ describe('Typert Remote streams', () => { it('delivers a pending waterfall to the first Client that connects', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -805,7 +806,7 @@ describe('Typert Remote streams', () => { it('replays a pending event id to a replacement Client generation', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() ctx.typert.contexts.registerHost('agent', { wire: 'agentId', @@ -839,7 +840,7 @@ describe('Typert Remote streams', () => { it('cancels pending deliveries when the Host signal or Context ends', async () => { const { ctx } = await setup(true) const source = new RemoteEventSourceProbe() - const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const signalAgent = ctx.extend() const contextFiber = ctx.plugin(() => {}) await contextFiber @@ -929,7 +930,7 @@ describe('Typert Remote streams', () => { const unregister = ctx.typertGateway.registerRemoteEvents(() => { sourceCalls += 1 return (async function *(): AsyncIterable {})() - }) + }, REMOTE_HOST) const invalidPayloads: readonly unknown[] = [ null, [], diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 92eeecb5e0..a77e4a22af 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -483,7 +483,7 @@ class RemoteEventCarrier { const abort = (): void => { connection.wake?.() } signal.addEventListener('abort', abort, { once: true }) try { - yield { type: 'ready', clientId } + yield { type: 'ready', clientId, host: { home: '/home/fixture' } } while (!signal.aborted) { while (connection.items.length > 0) { const item = connection.items.shift() as EventStreamItem @@ -1769,7 +1769,7 @@ describe('Client Typert API', () => { socket.receive({ type: 'item', streamId: opened.streamId, - value: { type: 'ready', clientId: 'browser-client' }, + value: { type: 'ready', clientId: 'browser-client', host: { home: '/home/browser' } }, }) await run.ready socket.receive({ @@ -1825,6 +1825,7 @@ describe('Client Typert API', () => { await vi.waitFor(() => { expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') + expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture') }) } finally { await ctx.fiber.dispose() @@ -1841,6 +1842,10 @@ describe('Client Typert API', () => { { type: 'ready' }, { type: 'ready', clientId: '' }, { type: 'ready', clientId: 'client', extra: true }, + { type: 'ready', clientId: 'client', host: null }, + { type: 'ready', clientId: 'client', host: {} }, + { type: 'ready', clientId: 'client', host: { home: 1 } }, + { type: 'ready', clientId: 'client', host: { home: '/home', extra: true } }, { type: 'emit', event: 'fixture/changed', args: ['too early'] }, ])('rejects malformed forwarded-event readiness item %#', async (opening) => { const open: NonNullable = () => (async function *() { diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index c0455bba10..ab1ee9d6b9 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1084,11 +1084,14 @@ describe('TypertGatewayService', () => { if (signal.aborted) resolve() else signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - })()) + })(), { home: '/home/fixture' }) const carrier = new AbortController() const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) const opening = await events.next() - expect(opening).toMatchObject({ done: false, value: { type: 'ready' } }) + expect(opening).toMatchObject({ + done: false, + value: { type: 'ready', host: { home: '/home/fixture' } }, + }) if (opening.done) throw new Error('Remote event stream ended before ready') const clientId: unknown = Reflect.get(opening.value as object, 'clientId') if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id') diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts index 0d134f3d99..e5e02b2d28 100644 --- a/packages/api/gateway/tests/journal-stream.client.spec.ts +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -42,10 +42,8 @@ interface Generation { type PageSource = Page | Promise | ((signal: AbortSignal) => Promise) const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 4d0256e162..e775d3a5ae 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,5 +1,6 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ +import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' import type { TypertRemoteEventDispatch, @@ -35,7 +36,7 @@ export const inject = ['typertGateway'] /** Host plugin body registering this application's selected Cordis event source. */ export function apply(ctx: Context): void { ctx.effect( - () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)), + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx), { home: homedir() }), 'api-remotes: forwarded Cordis event source', ) } diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index eefe64e664..1d2e8c235d 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -1,6 +1,7 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + RemoteEventHostInfo, TypertRemoteEventInvocation, TypertRemoteEventSource, } from '@deepseek-ai/dsh-api-gateway' @@ -10,8 +11,12 @@ import { apply, inject } from '../src/index.ts' interface GatewayProbe { source: TypertRemoteEventSource | undefined + host: RemoteEventHostInfo | undefined removals: number - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise } async function setup(): Promise<{ @@ -22,12 +27,15 @@ async function setup(): Promise<{ const ctx = new Context() const gateway: GatewayProbe = { source: undefined, + host: undefined, removals: 0, - registerRemoteEvents(source) { + registerRemoteEvents(source, host) { gateway.source = source + gateway.host = host return async () => { if (gateway.source !== source) return gateway.source = undefined + gateway.host = undefined gateway.removals += 1 } }, @@ -71,6 +79,14 @@ function invocationOf(value: unknown): TypertRemoteEventInvocation { } describe('Remote event Host source', () => { + it('registers the Host home used by Client connection generations', async () => { + const { gateway, fiber } = await setup() + expect(gateway.host?.home).toBeTypeOf('string') + expect(gateway.host?.home.length).toBeGreaterThan(0) + await fiber.dispose() + expect(gateway.host).toBeUndefined() + }) + it('gives each Client stream an independent allowlisted event queue', async () => { const { ctx, gateway, fiber } = await setup() const firstAbort = new AbortController() diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index bca9f9d3d0..04b7cbb553 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -111,7 +111,7 @@ export function apply(ctx: Context): void { }) control.start() ctx.on('connection/reset', () => { sessions.handleConnected() }) - if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() + if (connection.generation.getSnapshot() !== undefined) sessions.handleConnected() ctx.typert.contexts.registerClient('agent', { identity: candidate => sessions.scopeOf(candidate), resolve: sessionId => sessions.resolveAgentScope(sessionId), diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 876786a759..b6c7e40972 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -64,6 +64,24 @@ async function mount(initialHost?: HostDescription): Promise { return () => { hostListeners.delete(listener) } }, }, + generation: { + getSnapshot: () => host === undefined + ? undefined + : { id: 1, host: { home: host.home } }, + subscribe: (listener) => { + hostListeners.add(listener) + return () => { hostListeners.delete(listener) } + }, + }, + generation: { + getSnapshot: () => host === undefined + ? undefined + : { id: 1, host: { home: host.home } }, + subscribe: (listener) => { + hostListeners.add(listener) + return () => { hostListeners.delete(listener) } + }, + }, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index f1369e4c8c..851ce01030 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -32,10 +32,8 @@ import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' const AVAILABLE_STREAM_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/h' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 1ad1d4e858..36afa47632 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -28,10 +28,8 @@ type SessionTransportRemote = Pick const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never } const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index d71cfba44e..14cf3a02db 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -36,17 +36,15 @@ import type { } from '../src/types.ts' const AVAILABLE_CONNECTION = { - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, - }), + generation: { + getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {}, }, } function workspaceClient( remote: WorkspaceRemote, - connection: Pick = AVAILABLE_CONNECTION, + connection: Pick = AVAILABLE_CONNECTION, ) { return { workspace: remote, @@ -211,6 +209,7 @@ function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { }), subscribe: () => () => {}, }, + generation: AVAILABLE_CONNECTION.generation, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), }, diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index cad2de394d..ebcac74ea7 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,5 +1,19 @@ import type { HostDescription, IApiClient } from './api.ts' +/** Stable Host facts delivered by one established Remote event generation. */ +export interface ConnectionHostInfo { + /** Host account home used only to abbreviate displayed filesystem paths. */ + readonly home: string +} + +/** One successfully established Host generation. */ +export interface ConnectionGeneration { + /** Monotone generation number within this Client runtime. */ + readonly id: number + /** Host facts carried by this generation's opening frame. */ + readonly host: ConnectionHostInfo +} + /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ export interface ConnectionConfig { @@ -39,7 +53,7 @@ export type ConnectionState = 'connected' | 'reconnecting' /** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { /** After the generation source is ready and host.describe succeeds, first connect included. */ - onConnected?: (description: HostDescription) => void + onConnected?: (description: HostDescription, host: ConnectionHostInfo) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -55,7 +69,7 @@ export interface ConnectionSinks { */ export type ConnectionGenerationSource = ( signal: AbortSignal, - ready: () => void, + ready: (host: ConnectionHostInfo) => void, ) => Promise /** @@ -117,19 +131,20 @@ export class ConnectionController { this.current = ac let sourceReady = false - let resolveReady!: () => void + let resolveReady!: (host: ConnectionHostInfo) => void let rejectReady!: (error: Error) => void let rejectSourceLost!: (error: Error) => void - const ready = new Promise((resolve, reject) => { + const ready = new Promise((resolve, reject) => { resolveReady = resolve rejectReady = reject }) const sourceLost = new Promise((_resolve, reject) => { rejectSourceLost = reject }) - const reportReady = (): void => { + const reportReady = (host: ConnectionHostInfo): void => { + if (sourceReady) return sourceReady = true - resolveReady() + resolveReady(host) } const failed = new Promise((resolve) => { @@ -161,7 +176,7 @@ export class ConnectionController { // The source reports ready only after its incremental listeners exist; // describe may complete in parallel, but consumers see neither result // until both sides of the baseline-plus-increment handshake are ready. - const [description] = await Promise.race([ + const [description, host] = await Promise.race([ Promise.all([ this.api.host.describe({}, ac.signal), waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), @@ -178,7 +193,7 @@ export class ConnectionController { // A state sink may synchronously stop this controller. Do not publish // a description for a generation that no longer exists afterward. if (this.isGenerationActive(ac)) { - this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value) }) + this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value, host) }) } } catch { // Transport failure: treat as generation failure, fall through to the shared backoff. @@ -213,28 +228,28 @@ export class ConnectionController { } /** Await source readiness without letting a stalled carrier wedge startup forever. */ -function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { +function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { let settled = false const timeout = setTimeout(() => { - finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`)) + finish({ error: new Error(`connection generation was not ready within ${String(timeoutMs)}ms`) }) }, timeoutMs) const aborted = (): void => { - finish(new Error('connection generation aborted', { cause: signal.reason })) + finish({ error: new Error('connection generation aborted', { cause: signal.reason }) }) } - const finish = (error?: Error): void => { + const finish = (outcome: { readonly value: T } | { readonly error: Error }): void => { if (settled) return settled = true clearTimeout(timeout) signal.removeEventListener('abort', aborted) - if (error === undefined) resolve() - else reject(error) + if ('error' in outcome) reject(outcome.error) + else resolve(outcome.value) } signal.addEventListener('abort', aborted, { once: true }) void ready.then( - () => { finish() }, + (value) => { finish({ value }) }, (error: unknown) => { - finish(error as Error) + finish({ error: error as Error }) }, ) }) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index a2a7617a7e..6a5a1017c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -176,6 +176,7 @@ interface FixtureRemoteEventResult { interface FixtureRemoteEventReadyFrame { readonly type: 'ready' readonly clientId: string + readonly host: { readonly home: string } } interface FixtureProjectionFrame { @@ -3161,7 +3162,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) }, 5000) try { - yield { type: 'ready', clientId } + yield { type: 'ready', clientId, host: { home: FIXTURE_HOME } } if (approvalPending) yield approvalInvocation() if (questionPending) yield questionInvocation() yield* conn.drain(signal) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a24d014496..a03ab1e5d7 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -7,9 +7,9 @@ import type { HostDescription, IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, + type ConnectionGeneration, type ConnectionGenerationSource, type ConnectionSinks, - type ConnectionState, } from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' @@ -45,7 +45,14 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. -export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState } +export type { + ConnectionConfig, + ConnectionGeneration, + ConnectionGenerationSource, + ConnectionHostInfo, + ConnectionSinks, + ConnectionState, +} from './connection.ts' export type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, } from '../rpc.ts' @@ -59,6 +66,14 @@ export interface HostDescriptionSource { subscribe(listener: () => void): () => void } +/** Observable identity and Host facts for the active connection generation. */ +export interface ConnectionGenerationState { + /** Active generation, or undefined before readiness and while reconnecting. */ + getSnapshot(): ConnectionGeneration | undefined + /** Subscribe to generation establishment, replacement, and loss. */ + subscribe(listener: () => void): () => void +} + /** Required services (none — this is the wire root). */ export const inject: string[] = [] @@ -113,6 +128,8 @@ export interface ConnectionHandle { readonly isLoopback: boolean /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource + /** Current Remote event generation and the Host facts carried by its opening frame. */ + readonly generation: ConnectionGenerationState /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc /** @@ -151,6 +168,9 @@ export function apply(ctx: Context): void { const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined + let generationId = 0 + let generation: ConnectionGeneration | undefined + const generationListeners = new Set<() => void>() let description: HostDescription | undefined const descriptionListeners = new Set<() => void>() const publishDescription = (next: HostDescription | undefined): void => { @@ -164,10 +184,22 @@ export function apply(ctx: Context): void { } } } + const publishGeneration = (next: ConnectionGeneration | undefined): void => { + if (Object.is(generation, next)) return + generation = next + for (const listener of [...generationListeners]) { + try { + listener() + } catch (error) { + console.error('[connection] generation listener threw:', error) + } + } + } const releaseOwner = (current: ConnectionOwner): void => { if (owner !== current) return owner = undefined current.controller.stop() + publishGeneration(undefined) publishDescription(undefined) } const handle: ConnectionHandle = { @@ -180,6 +212,13 @@ export function apply(ctx: Context): void { return () => { descriptionListeners.delete(listener) } }, }, + generation: { + getSnapshot: () => generation, + subscribe: (listener) => { + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } + }, + }, rpc, registerGenerationSource(source) { if (generationSource !== undefined) { @@ -201,17 +240,23 @@ export function apply(ctx: Context): void { const ownsGeneration = (): boolean => owner?.token === token const controller = new ConnectionController(api, source, { ...sinks, - onConnected: (next) => { + onConnected: (next, host) => { + const nextGeneration = { id: ++generationId, host } + publishGeneration(nextGeneration) + if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return publishDescription(next) // A description subscriber may synchronously stop the loop. In that // case publishDescription(undefined) has already retracted this // generation, so do not leak its stale connected notification to // the consumer sink afterward. if (!ownsGeneration() || !Object.is(description, next)) return - sinks.onConnected?.(next) + sinks.onConnected?.(next, host) }, onStateChange: (state) => { - if (state === 'reconnecting') publishDescription(undefined) + if (state === 'reconnecting') { + publishGeneration(undefined) + publishDescription(undefined) + } if (!ownsGeneration()) return sinks.onStateChange?.(state) }, diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index 443d398eba..fb5257e355 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -37,7 +37,7 @@ class GenerationProbe { } this.active.add(finish) signal.addEventListener('abort', finish, { once: true }) - ready() + ready({ home: '/h' }) if (signal.aborted) finish() }) diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 03f57f9273..40f9a5ec6c 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -192,7 +192,7 @@ describe('connection lifecycle', () => { const controller = new ConnectionController(api, (signal, ready) => { sourceCalls++ if (sourceCalls === 1) return fail() - ready() + ready({ home: '/h' }) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index bc546ad185..639f52f2b9 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -95,7 +95,10 @@ export class FakeApiClient implements IApiClient { return response } - private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise { + private async openGeneration( + signal: AbortSignal, + onOpen: (host: { readonly home: string }) => void, + ): Promise { const inbox: StreamItem[] = [] let wake: (() => void) | null = null const conn: StreamConn = { @@ -105,8 +108,9 @@ export class FakeApiClient implements IApiClient { }, } this.generationConns.push(conn) - if (this.holdGenerationReady) this.heldOpens.push(onOpen) - else if (!this.suppressGenerationReady) onOpen() + const ready = (): void => { onOpen({ home: '/h' }) } + if (this.holdGenerationReady) this.heldOpens.push(ready) + else if (!this.suppressGenerationReady) ready() try { while (!signal.aborted) { while (inbox.length > 0) { diff --git a/packages/client/connection/tests/generation.client.spec.ts b/packages/client/connection/tests/generation.client.spec.ts new file mode 100644 index 0000000000..0deace5dba --- /dev/null +++ b/packages/client/connection/tests/generation.client.spec.ts @@ -0,0 +1,65 @@ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + apply, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '../src/client/index.ts' + +type BrowserGlobal = { + location?: { hostname: string; search: string } +} + +const contexts = new Set() + +afterEach(async () => { + vi.restoreAllMocks() + delete (globalThis as BrowserGlobal).location + await Promise.all([...contexts].map(async ctx => ctx.fiber.dispose())) + contexts.clear() +}) + +async function mount(): Promise { + ;(globalThis as BrowserGlobal).location = { hostname: 'localhost', search: '?fixture' } + const ctx = new Context() + contexts.add(ctx) + await ctx.plugin({ apply, inject: [] }) + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('fixture did not provide Connection') + return connection +} + +describe('Connection generation facts', () => { + it('publishes ready-frame Host facts and retracts them when the loop stops', async () => { + const connection = await mount() + const source: ConnectionGenerationSource = (signal, ready) => { + ready({ home: '/home/from-ready' }) + return new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + connection.registerGenerationSource(source) + const seen: Array = [] + const stopListening = connection.generation.subscribe(() => { + seen.push(connection.generation.getSnapshot()?.host.home) + }) + const loop = connection.start({}, { + backoffBaseMs: 1, + backoffFactor: 1, + backoffMaxMs: 1, + generationReadyTimeoutMs: 100, + }) + + await vi.waitFor(() => { + expect(connection.generation.getSnapshot()).toEqual({ + id: 1, + host: { home: '/home/from-ready' }, + }) + }) + loop.stop() + expect(connection.generation.getSnapshot()).toBeUndefined() + expect(seen).toEqual(['/home/from-ready', undefined]) + stopListening() + }) +}) From 40929d6e1ad983e8ebeed94e4639e1f4bbf22b64 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:49:26 +0800 Subject: [PATCH 110/130] refactor(client): replace host description consumers --- .../tests/agent-preset-authoring.overlay.yml | 6 +- apps/web/tests/produced-files.overlay.yml | 2 +- packages/api/session-controller/src/index.ts | 19 ++++- .../tests/client-apply.client.spec.ts | 55 ++++--------- .../tests/fake-api.client.ts | 21 +---- .../session-open-workspace-path.host.spec.ts | 33 ++++++++ .../session-controller/tests/test-remote.ts | 18 ++++- packages/api/settings-controller/src/index.ts | 9 +++ .../tests/settings-controller.host.spec.ts | 3 + .../tests/transport.client.spec.ts | 11 --- .../ui-agent-preset/src/client/index.ts | 6 +- .../src/client/section-store.ts | 7 +- .../tests/apply.client.spec.ts | 14 +--- .../tests/section-store.client.spec.ts | 40 +++------- packages/client/ui-deliverables/package.json | 4 + .../src/client/ProducedFiles.tsx | 16 ++-- .../ui-deliverables/src/client/index.ts | 35 +++++++- .../tests/produced-files.client.spec.tsx | 80 ++++++++++++++++--- packages/client/ui-deliverables/tsconfig.json | 6 ++ .../client/ui-reference/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 4 +- packages/client/ui-tool/src/client/apply.ts | 2 +- .../ui-tool/src/client/contract/slots.ts | 12 +-- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 4 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 6 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../tests/ask-question-row.client.spec.tsx | 4 +- .../tests/assembly-surfaces.client.spec.tsx | 3 +- .../tests/chat-code-subcalls.client.spec.tsx | 3 +- .../ui-tool/tests/read-card.client.spec.tsx | 4 +- .../tests/tool-call-tree.client.spec.tsx | 10 +-- .../tests/tool-details-render.client.tsx | 8 +- .../tests/toolview-slot.client.spec.tsx | 6 +- .../ui-workspace/src/client/contract/slots.ts | 4 +- .../client/ui-workspace/src/client/index.ts | 4 +- .../src/client/rows/WorkspaceBrowser.tsx | 4 +- .../ui-workspace/tests/apply.client.spec.ts | 4 +- .../tests/rename-assembly.client.spec.tsx | 2 +- .../tests/workspace-browser.client.spec.tsx | 6 +- 40 files changed, 283 insertions(+), 198 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml index d39b5307ea..3791809a7e 100644 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -1,14 +1,12 @@ # The authoring lane drives the location affordance. A real desktop open # would pop a file manager on the machine running the tests and the # capability itself is platform-detected (macOS yes, headless Linux CI no), -# so the gateway is pinned headless: `hasDocument` is false everywhere and +# so both native-open owners are pinned headless: `hasDocument` is false everywhere and # `openDocument` answers the directory as text — the same branch on every # host, and the one whose rendering a golden can hold. A patch replaces the # row's complete config, so the shipped routing defaults ride along. -- id: api-gateway +- id: session-controller config: - provider: deepseek-official - model: deepseek-v4-flash nativeOpen: false - id: settings-controller config: diff --git a/apps/web/tests/produced-files.overlay.yml b/apps/web/tests/produced-files.overlay.yml index ceac487918..4267b6d2cf 100644 --- a/apps/web/tests/produced-files.overlay.yml +++ b/apps/web/tests/produced-files.overlay.yml @@ -1,7 +1,7 @@ # The summary test asserts the native-folder action without launching it. Pin # the capability so headless Linux CI and desktop developer hosts expose the # same UI branch; platform opener behavior belongs to the Host unit tests. -- id: api-gateway +- id: session-controller config: nativeOpen: true - id: settings-controller diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b58cdfcbad..342dd977eb 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' -import { openNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -67,12 +67,16 @@ declare module '@deepseek-ai/cordis' { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } /** Host integrations replaceable by direct unit tests. */ export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native handoff availability probe. */ + readonly canOpenPath?: () => boolean } /** Host service backing the generated `ctx.remote.session` namespace. */ @@ -91,6 +95,7 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), + nativeOpen: z.boolean(), }) private readonly agents: ApiSessionAgentController @@ -99,6 +104,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly canOpenPath: () => boolean private readonly promotions = new Set>() /** @@ -122,6 +128,8 @@ export class SessionController extends TypertRemoteService { config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, ) this.openPath = internals.openPath ?? openNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -242,6 +250,15 @@ export class SessionController extends TypertRemoteService { return buildModelCatalog(this.ctx) } + /** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenWorkspacePath(): boolean { + return this.canOpenPath() + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index b6c7e40972..2ecaff6804 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -1,8 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + ConnectionGeneration, ConnectionHandle, - HostDescription, } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError, @@ -16,13 +16,7 @@ import * as SessionClient from '../src/client/index.ts' import { ClientSessions } from '../src/client/sessions/service.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' -const DESCRIPTION: HostDescription = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } } const sid = (value: string): SessionId => value as SessionId @@ -34,7 +28,7 @@ interface Bench { readonly fiber: Fiber readonly sessions: ClientSessions dispatch(event: string, ...args: unknown[]): void - publishHost(description: HostDescription | undefined): void + publishGeneration(generation: ConnectionGeneration | undefined): void } const contexts = new Set() @@ -45,41 +39,22 @@ afterEach(async () => { contexts.clear() }) -async function mount(initialHost?: HostDescription): Promise { +async function mount(initialGeneration?: ConnectionGeneration): Promise { const ctx = new Context() contexts.add(ctx) await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const remote = fakeRemote(api) const listeners = new Map>() - const hostListeners = new Set<() => void>() - let host = initialHost + const generationListeners = new Set<() => void>() + let generation = initialGeneration const connection: ConnectionHandle = { - api, isLoopback: true, - hostDescription: { - getSnapshot: () => host, - subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } - }, - }, generation: { - getSnapshot: () => host === undefined - ? undefined - : { id: 1, host: { home: host.home } }, + getSnapshot: () => generation, subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } - }, - }, - generation: { - getSnapshot: () => host === undefined - ? undefined - : { id: 1, host: { home: host.home } }, - subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } }, }, rpc: { @@ -115,9 +90,9 @@ async function mount(initialHost?: HostDescription): Promise { dispatch: (event, ...args) => { for (const listener of listeners.get(event) ?? []) listener(...args as never[]) }, - publishHost: (description) => { - host = description - for (const listener of [...hostListeners]) listener() + publishGeneration: (next) => { + generation = next + for (const listener of [...generationListeners]) listener() }, } } @@ -166,7 +141,7 @@ describe('Session Controller Client apply', () => { it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(accept).toHaveBeenCalledWith({ @@ -198,7 +173,7 @@ describe('Session Controller Client apply', () => { }) it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(bench.sessions.list.getSnapshot().phase).toBe('ready') @@ -230,7 +205,7 @@ describe('Session Controller Client apply', () => { await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) - bench.publishHost(DESCRIPTION) + bench.publishGeneration(GENERATION) await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) }) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 851ce01030..cb6a635bef 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -1,8 +1,8 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo +// Test-local programmable Remote fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { - IApiClient, MessageId, + MessageId, RpcError, RpcResponse, SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, @@ -122,7 +122,7 @@ export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { return api.sessionRemotes() } -export class FakeApiClient implements IApiClient { +export class FakeApiClient { /** Chronological call record: [method, payload]. */ readonly calls: { method: string; payload: unknown }[] = [] /** Session ids in physical follow-generation opening order. */ @@ -157,16 +157,6 @@ export class FakeApiClient implements IApiClient { onOpenWorkspacePath: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ opened: true as const })) - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -191,10 +181,6 @@ export class FakeApiClient implements IApiClient { onSubagentInterrupt: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ accepted: true as const })) - readonly host: IApiClient['host'] = { - describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - } - onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -223,6 +209,7 @@ export class FakeApiClient implements IApiClient { execute: () => Promise.resolve({ ok: true, value: undefined }), }, session: { + canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)), list: payload => this.remoteResult('session.list', payload, this.onList(payload)), modelCatalog: () => Promise.resolve({ ok: true, diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee24947227..2c1feb292f 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -15,6 +15,39 @@ async function context(): Promise { } describe('session/openWorkspacePath', () => { + it('reports the deployment opener capability independently of a Session', async () => { + const ctx = await context() + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + canOpenPath: () => false, + }) + + await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + }) + + it('derives opener availability from config, an injected opener, or the platform probe', async () => { + const configured = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + nativeOpen: false, + }) + await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + + const injected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath: () => Promise.resolve(), + }) + await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) + + const detected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true }) + }) + it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 77e813b324..e2e6292bce 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -51,6 +51,7 @@ import type { /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { + canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> @@ -76,8 +77,10 @@ export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string readonly coldBlankProbeMaxBytes?: number + readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly canOpenPath?: () => boolean } const installed = new WeakMap() @@ -185,10 +188,16 @@ function installControllers( try { controller = new SessionController( ctx, - defaults.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, - defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + { + ...defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, + ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + }, + { + ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, + }, ) } finally { cwd.mockRestore() @@ -233,6 +242,7 @@ export function createSessionTestRemote( ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { + canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), signal, diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index e822d7f489..5fa81d1518 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -128,6 +128,15 @@ export class SettingsController extends TypertRemoteService { } } + /** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenAgentPresetDirectory(): boolean { + return this.canOpenPath() + } + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index aa8945b4fc..7195e6650f 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -82,6 +82,7 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(controller.typertRemote.namespace).toBe('settings') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } }, { method: 'update', invocation: { kind: 'direct' } }, { method: 'replace', invocation: { kind: 'direct' } }, { method: 'mutate', invocation: { kind: 'direct' } }, @@ -348,6 +349,7 @@ describe('the settings Remote namespace a configuration page calls', () => { } as never) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + expect(openable.canOpenAgentPresetDirectory()).toBe(true) const signal = new AbortController().signal await expect(openable.openAgentPresetDirectory('mine', signal)) .resolves.toEqual({ opened: true }) @@ -360,6 +362,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }), } as never) const reveal = new SettingsController(headless, { nativeOpen: false }) + expect(reveal.canOpenAgentPresetDirectory()).toBe(false) await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal)) .resolves.toEqual({ opened: false, path: '/presets/mine' }) }) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 14cf3a02db..bdd01d33ea 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -197,18 +197,7 @@ async function waitFor(check: () => void): Promise { function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { const connection: ConnectionHandle = { - api: {} as ConnectionHandle['api'], isLoopback: true, - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, - }), - subscribe: () => () => {}, - }, generation: AVAILABLE_CONNECTION.generation, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4019655d72..4926d7c001 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -11,7 +11,6 @@ * before-the-fact, while the header only reports what a session already runs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the Session Controller service merge (ctx.sessions). import type {} from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -51,7 +50,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ] /** @@ -59,13 +58,12 @@ export const inject = [ * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const { api } = ctx.get('connection') as ConnectionHandle const settingsWire = { settings: ctx.remote.settings } const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(api, ctx.remote, () => { + const section = new AgentPresetSectionController(ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 70baab32ec..43db65686d 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,7 +14,7 @@ * more than the row it targeted. */ -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -133,7 +133,6 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other @@ -168,13 +167,13 @@ export class AgentPresetSectionController { // Issued together: one round trip decides the page, and a load that waited // for them in turn would hold the section in `loading` twice as long, // where a concurrent reload silently returns instead of refreshing. - const opener = this.api.host.describe({}) + const opener = this.remote.settings.canOpenAgentPresetDirectory() const roster = await beginRosterRead(this.remote, this.store) // A refused describe leaves the reveal-the-path path, which needs no opener. const described = await opener.catch(() => undefined) if (roster === undefined) return const { presets, authorable } = roster - const hasDocument = described?.result.ok === true && described.result.value.canOpenPath + const hasDocument = described?.ok === true && described.value if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index b7e73785e4..c2488de4af 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -74,6 +74,7 @@ async function bench() { // The row reads `describe` to learn whether this browser may write at all, // and its default write is the one op this spec records. const settings = { + canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }), describe: () => Promise.resolve({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, @@ -114,16 +115,7 @@ async function bench() { } ctx.provide('remote.agentPresets', agentPresets as never) Object.assign(remote, { agentPresets }) - ctx.provide('connection', { - api: { - host: { - describe: () => Promise.resolve({ - rpcId: 'r', - result: { ok: true as const, value: { canOpenPath: true } }, - }), - }, - }, - } as never) + ctx.provide('connection', { isLoopback: true } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } @@ -185,7 +177,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ]) }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index df1ad1e201..0b732c4cf5 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -41,36 +41,16 @@ interface FakeOptions { authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean - /** Reject `host.describe`, as a dead transport does. */ - throwDescribe?: boolean + /** Reject the opener capability read, as a dead transport does. */ + throwCapability?: boolean /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } -const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) const remoteFail = (message: string) => Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) -/** - * The carried wire face: the desktop opener, the default write, and the opener - * capability the page joins onto the roster. - * @param defaultId - the preset a session with no choice gets. - * @param options - failure injection and call recording. - * @returns the fake client. - */ -function fakeApi( - options: FakeOptions = {}, -): Pick { - return { - host: { - describe: () => (options.throwDescribe === true - ? Promise.reject(new Error('socket closed')) - : ok({ canOpenPath: options.hasDocument ?? true })), - }, - } as Pick -} - /** * The Remote namespace over an in-memory preset store: copies land, so the * roster the controller re-reads after a copy is the one the copy produced. @@ -143,6 +123,12 @@ function fakeRemote( }, }, settings: { + canOpenAgentPresetDirectory: () => { + record('canOpenAgentPresetDirectory', {}) + return options.throwCapability === true + ? Promise.reject(new Error('socket closed')) + : remoteOk(options.hasDocument ?? true) + }, update: (ns: string, patch: { default?: string }) => { record('settings.update', { ns, patch }) if (options.failSettings !== undefined) return remoteFail(options.failSettings) @@ -176,7 +162,6 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeApi(wired), fakeRemote(presets, defaultId, wired), () => { rosterChanges += 1 }, ) @@ -191,11 +176,11 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft { describe('loading the roster', () => { it('still lists the roster when the opener capability cannot be read', async () => { - const { controller } = harness({ throwDescribe: true }) + const { controller } = harness({ throwCapability: true }) await controller.load() - // The two reads are independent: a refused `host.describe` costs the + // The two reads are independent: a refused capability query costs the // open-directory affordance, not the page. const state = controller.store.getSnapshot() expect(state.status).toBe('ready') @@ -572,7 +557,6 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { host: {} } as unknown as Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), @@ -596,7 +580,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeApi(), fakeRemote(presets, defaultId)) + fakeRemote(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 30b6de4e7f..15dce11815 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", @@ -47,6 +48,7 @@ }, "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", @@ -58,8 +60,10 @@ "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 6841e734e7..b3f0f04c0a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,6 +1,5 @@ -import { useLayoutEffect, useRef, useState } from 'react' -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -44,9 +43,11 @@ export function fitProducedFiles( export interface ProducedFilesInjected { /** Whether the browser itself is connected over loopback. */ isLoopback: boolean + /** Load the opener capability when this row first reaches the page. */ + ensureWorkspacePathOpen(): void hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current generation's Session workspace opener capability. */ + workspacePathOpen: HostObservable } } @@ -65,9 +66,10 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string { * @returns The produced-files row. */ export function ProducedFiles({ - matched: paths, openFile, isLoopback, useHostDescription, t, + matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t, }: ProducedFilesProps) { - const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true) + useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen]) + const hostCanOpenPath = useWorkspacePathOpen(available => available === true) const canOpenPath = isLoopback && hostCanOpenPath const limit = Math.min(paths.length, SHOWN_LIMIT) const [shownCount, setShownCount] = useState(limit) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 0e8767016e..3be17e7da4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,8 @@ */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -30,7 +32,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'connection'] +export const inject = ['slots', 'locale', 'uiConversation', 'connection', 'remote', 'remote.session'] /** * Client plugin body: register the dictionaries and the turn-tail entry. @@ -38,6 +40,34 @@ export const inject = ['slots', 'locale', 'uiConversation', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle + const workspacePathOpen = createSnapshotStore(undefined) + let requestedWorkspacePathOpen = false + let capabilityRevision = 0 + let pendingCapability: Promise | undefined + const loadWorkspacePathOpen = (): void => { + if (pendingCapability !== undefined) return + const revision = capabilityRevision + const pending = ctx.remote.session.canOpenWorkspacePath() + .then((result) => { + if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value) + }, () => { + if (revision === capabilityRevision) workspacePathOpen.set(false) + }) + .finally(() => { + if (pendingCapability === pending) pendingCapability = undefined + }) + pendingCapability = pending + } + const ensureWorkspacePathOpen = (): void => { + requestedWorkspacePathOpen = true + if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen() + } + ctx.on('connection/reset', () => { + capabilityRevision++ + pendingCapability = undefined + workspacePathOpen.set(undefined) + if (requestedWorkspacePathOpen) loadWorkspacePathOpen() + }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -48,7 +78,8 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => ({ isLoopback: connection.isLoopback, - hooks: { hostDescription: connection.hostDescription }, + ensureWorkspacePathOpen, + hooks: { workspacePathOpen }, }), }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9117fa3925..5a0043971e 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -22,7 +22,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { - fitProducedFiles, ProducedFiles, type ProducedFilesProps, + fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps, } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, @@ -403,13 +403,11 @@ describe('ProducedFiles row', () => { const capability = ( canOpenPath: boolean | undefined, isLoopback = true, - ): Pick => { - const description = canOpenPath === undefined - ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } + ): Pick => { return { isLoopback, - useHostDescription: selector => selector(description), + ensureWorkspacePathOpen: () => {}, + useWorkspacePathOpen: selector => selector(canOpenPath), } } @@ -580,14 +578,17 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} } + const generation = { getSnapshot: () => undefined, subscribe: () => () => {} } ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription, + generation, } as never) // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) + const session = { + canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), + } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() @@ -595,7 +596,16 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } }) + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + expect(injected.isLoopback).toBe(false) + expect(typeof injected.ensureWorkspacePathOpen).toBe('function') + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + ctx.emit('connection/reset') + injected.ensureWorkspacePathOpen() + await vi.waitFor(() => { + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) + }) + injected.ensureWorkspacePathOpen() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -617,4 +627,52 @@ describe('plugin registration', () => { // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) + + it('queries the workspace opener lazily and replaces stale results after reconnect', async () => { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + new UiConversation(ctx, { binding: () => undefined } as never) + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + } as never, () => null) + ctx.provide('connection', { + isLoopback: true, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) + const first = Promise.withResolvers<{ ok: true; value: boolean }>() + const second = Promise.withResolvers<{ ok: true; value: boolean }>() + const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>() + const capability = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(staleFailure.promise) + .mockRejectedValueOnce(new Error('offline')) + const session = { canOpenWorkspacePath: capability } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = ctx.slots.entries('conversation.chat.turnTail')[0] + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + + injected.ensureWorkspacePathOpen() + injected.ensureWorkspacePathOpen() + expect(capability).toHaveBeenCalledOnce() + ctx.emit('connection/reset') + expect(capability).toHaveBeenCalledTimes(2) + first.resolve({ ok: true, value: false }) + await Promise.resolve() + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + second.resolve({ ok: true, value: true }) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) }) + + ctx.emit('connection/reset') + ctx.emit('connection/reset') + staleFailure.reject(new Error('stale offline')) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) + await fiber.dispose() + }) }) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 51e6d50b5d..9c29b3efdc 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -17,6 +20,9 @@ { "path": "../locale" }, + { + "path": "../store" + }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index c82adcdbe4..09ecf5aaa9 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -64,7 +64,7 @@ export function apply(ctx: ClientContext): void { // when there is no header to carry it. const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined const now = Date.now() - const home = connection.hostDescription.getSnapshot()?.home + const home = connection.generation.getSnapshot()?.host.home const listed = sessions.list.getSnapshot().byId return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 03e19b1b27..4565e170db 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -92,7 +92,7 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } }) + ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -124,7 +124,7 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } }) + ctx.provide('connection', { generation: { getSnapshot: () => undefined } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index dce00a0f54..385dc58edf 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -24,7 +24,7 @@ export const inject = ['slots', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) + const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index c9206c8cd9..ce39262499 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,5 @@ /** Tool UI slot declarations and their composed component props. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -47,10 +47,10 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostDescriptionInjected = { +export type ToolConnectionGenerationInjected = { hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current Connection generation, bound by the slot renderer. */ + connectionGeneration: ConnectionGenerationState } } @@ -58,9 +58,9 @@ export type ToolHostDescriptionInjected = { export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 2079d09a96..e27656cebb 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index ebb4578281..6a15997531 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t, }: ToolTreeProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const block = node.data.root return ( ) { - const home = useHostDescription(description => description?.home) + block, cwd, useConnectionGeneration, t, +}: Pick) { + const home = useConnectionGeneration(generation => generation?.host.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 44fca5b9e4..4775686284 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -140,7 +140,7 @@ type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'> export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) { const model = toolRowModel(toolName, block) // Composer verdicts settle the call as specific UserQuestionErrors - // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own + // (ask_user_question handler): 'ASK_CANCELLED' is the user's own // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the // question was pending. Both name their verdict instead of the generic // failed shape, and the abort keeps the shared stopped (amber) semantics of diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 22f1e0d5e4..6904b7dfad 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -168,7 +168,7 @@ describe('AskQuestionRow', () => { }) it('user cancellation shows the original questions without raw JSON or an error body', () => { - // ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error. + // ASK_CANCELLED: the ask_user_question handler's cancel error. const view = render() expect(screen.getByText('已取消')).toBeTruthy() @@ -184,7 +184,7 @@ describe('AskQuestionRow', () => { }) it('a turn abort shows the original questions with stopped semantics', () => { - // ASK_ABORTED: the apiproxy ask handler's turn-abort settlement. + // ASK_ABORTED: the ask handler's turn-abort settlement. const view = render() expect(screen.getByText('已中断')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 63cea1a148..960c81f89b 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -72,9 +72,8 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) new TestRemote(runtime.ctx, { session: { diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index a23e003800..bb0b40b74d 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -121,9 +121,8 @@ async function bench(snapshot: ChatSnapshot) { ctx.provide('uiWorkspace', {} as never) new TestRemote(ctx, { session: { openWorkspacePath } }) ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 40b7042f30..d61b1fe6b7 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -361,9 +361,7 @@ describe('DetailsPanel Output section (read)', () => { it('abbreviates a leftover POSIX home path on the read card label', () => { const view = mount(snapshot({ nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })], - }), target, '/tmp/ws', { - version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, - }) + }), target, '/tmp/ws', { id: 1, host: { home: '/Users/u' } }) expect(view.getByText('~/notes.md')).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 67cf3a7962..852ffd9ef5 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,7 +2,7 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -23,7 +23,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, - description?: HostDescription, + generation?: ConnectionGeneration, owners?: ToolCallOwnerProps[], ): ToolTreeProps { const snapshot = {} as SessionSnapshot @@ -50,7 +50,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'], + useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'], t, } as unknown as ToolTreeProps } @@ -98,9 +98,7 @@ describe('ToolCallTree', () => { it('abbreviates a POSIX home path in the generic tool summary', () => { const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) - const view = render() + const view = render() expect(view.getByText('~/docs/a.ts')).toBeTruthy() }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 96a08b57ac..b20b689724 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,5 +1,5 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { @@ -144,12 +144,12 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. - * @param description - optional Host description so the details card can abbreviate home paths. + * @param generation - optional Connection generation carrying the Host home. * @returns a direct-test renderSlot implementation. */ export function renderToolDetails( t: TranslateNS<'conversation'>, - description?: HostDescription, + generation?: ConnectionGeneration, ): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; @@ -158,7 +158,7 @@ export function renderToolDetails( return selector(description)} + useConnectionGeneration={selector => selector(generation)} t={t} /> } diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 719dbafb1a..4a6cedf03e 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -60,9 +60,8 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) @@ -207,9 +206,8 @@ 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.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) new TestRemote(runtime.ctx, { session: { diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index f25270aad3..d7f6be2c8a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,7 +22,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. @@ -90,7 +90,7 @@ export type DirectoryPickingHooks = PropsHooks workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, hostDescription }, + hooks: { directoryFlow: browserFlowSource, connectionGeneration }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 2b2aba93ce..c8a5a15740 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useHostDescription, + useConnectionGeneration, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 3834358288..941c251f56 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -59,7 +59,7 @@ async function bench() { fork, } as never) ctx.provide('connection', { - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) const directoryPicker = { pick: pickDirectory } @@ -162,7 +162,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined() + expect(browser.hooks.connectionGeneration.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index cc698e7391..ea349efcc5 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -35,7 +35,7 @@ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.releaseWorkspaceSource() runtime.ctx.provide('connection', { - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // The rename flow never picks a directory; the namespace only has to be there // for ui-workspace's inject to settle. diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index a623c3334d..22e4062abe 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -85,7 +85,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useHostDescription: selector => selector(undefined), + useConnectionGeneration: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
    : null)) as never, t, ...overrides, @@ -110,9 +110,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useHostDescription: selector => selector({ - version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false, - }), + useConnectionGeneration: selector => selector({ id: 1, host: { home: '/home/u' } }), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) }) From 3b40a145552be1e15b83be87789a5258f25674a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:50:19 +0800 Subject: [PATCH 111/130] test(session-export): assign Host compiler face --- apps/cli/tests/web-agent-presets.e2e.ts | 3 + .../session-log-export/package.json | 1 - .../session-log-export/src/index.ts | 19 +- .../tests/archive.host.spec.ts | 734 ++++++++++++++++++ ...nd.client.spec.ts => command.host.spec.ts} | 0 ....client.spec.ts => invariant.host.spec.ts} | 0 ...pec.ts => loader-composition.host.spec.ts} | 0 .../tests/route.host.spec.ts | 9 +- 8 files changed, 751 insertions(+), 15 deletions(-) create mode 100644 packages/session-query/session-log-export/tests/archive.host.spec.ts rename packages/session-query/session-log-export/tests/{command.client.spec.ts => command.host.spec.ts} (100%) rename packages/session-query/session-log-export/tests/{invariant.client.spec.ts => invariant.host.spec.ts} (100%) rename packages/session-query/session-log-export/tests/{loader-composition.client.spec.ts => loader-composition.host.spec.ts} (100%) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index a351cee4ee..7903cb6e9e 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -84,6 +84,9 @@ async function bootWeb( { id: 'skill-badge', disabled: false }, { id: 'modules', disabled: true }, { id: 'connection', disabled: true }, + // Export owns a Connection Fetch route, so this Host-only composition + // disables it with the transport service above. + { id: 'session-log-download', disabled: true }, // The always-on reload chain waits for the browser roster and bound port // disabled above. { id: 'client-hmr', disabled: true }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 9d7cd557e4..380357093c 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" }, diff --git a/packages/session-query/session-log-export/src/index.ts b/packages/session-query/session-log-export/src/index.ts index 4be729d3a0..a9cc725c6b 100644 --- a/packages/session-query/session-log-export/src/index.ts +++ b/packages/session-query/session-log-export/src/index.ts @@ -80,11 +80,16 @@ export function apply(ctx: Context, config: Config = {}): void { connectionOf(ctx).fetch.register({ path: SESSION_LOG_EXPORT_PATH, methods: ['GET', 'HEAD'], - fetch: request => sessionLogExportResponse( - ctx, - request, - config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, - ), + fetch: async (request) => { + const response = await sessionLogExportResponse( + ctx, + request, + config.compressionLevel ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, + ) + if (request.method === 'GET') return response + await response.body?.cancel() + return new Response(null, { status: response.status, headers: response.headers }) + }, }) } @@ -153,7 +158,5 @@ async function sessionLogExportResponse( }, }, ) - if (request.method === 'GET') return response - await response.body?.cancel() - return new Response(null, { status: response.status, headers: response.headers }) + return response } diff --git a/packages/session-query/session-log-export/tests/archive.host.spec.ts b/packages/session-query/session-log-export/tests/archive.host.spec.ts new file mode 100644 index 0000000000..c41846039a --- /dev/null +++ b/packages/session-query/session-log-export/tests/archive.host.spec.ts @@ -0,0 +1,734 @@ +/** + * session.export host path: the GET download endpoint streams a ZIP whose + * files are the stored artifacts verbatim (root + optional descendants), and + * the degenerate compositions fail loudly (missing services → 500, missing + * root → 404, missing descendant → errored stream). + */ + +import { randomBytes } from 'node:crypto' +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { unzipSync, strFromU8 } from 'fflate' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { HostConnectionService } from '@deepseek-ai/dsh-client-connection' +import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts' +import * as SessionLogExport from '../src/index.ts' + +const sid = (id: string): SessionId => id as SessionId + +function header(id: string, parentSession?: SessionId): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 1000, + cwd: '/proj', + ...parentSession === undefined ? {} : { parentSession }, + delegationDepth: parentSession === undefined ? 0 : 1, + } +} + +function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { + return { + meta: header(id, parentSession), + filename: 'session.jsonl', + content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, + } +} + +function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { + return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } +} + +/** One durable image object served by the fake attachment store. */ +function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') { + return { + ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef, + data: new Uint8Array([1, 2, 3, 4]), + } +} + +/** A user/message event line carrying one image reference. */ +function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { + return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` +} + +async function buildApi( + artifacts: Record, + descendants: SessionLineageNode[] = [], + services: { + query?: boolean + persistence?: boolean | 'throw' | 'unsupported' + attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) + sessions?: { + get(id: SessionId): { readonly id: SessionId } | undefined + flush(session: { readonly id: SessionId }): Promise + } + readRaw?: (id: SessionId, signal?: AbortSignal) => Promise + traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{ + target: { header: SessionHeader; live: boolean; persisted: boolean } + ancestors: readonly SessionLineageNode[] + complete: boolean + root: { header: SessionHeader; live: boolean; persisted: boolean } + descendants: readonly SessionLineageNode[] + }> + compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 + } = {}, +) { + const ctx = new Context() + ctx.provide('commands', { register: () => () => {} } as never) + const query = services.query ?? true + const persistence = services.persistence ?? true + if (query) { + ctx.provide('sessionQuery', { + traceSession: services.traceSession ?? (async () => ({ + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants, + })), + } as never) + } + if (persistence) { + ctx.provide('sessionPersistence', { + supportsRawArtifacts: persistence !== 'unsupported', + readRaw: services.readRaw ?? (async (id: SessionId) => { + if (persistence === 'throw') throw new Error('/host/private/session.jsonl') + return artifacts[id] + }), + } as never) + } + if (services.attachments !== false) { + const readImage = typeof services.attachments === 'function' + ? services.attachments + : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType) + ctx.provide('attachments', { + imageLimits: {} as never, + validateImage: async () => {}, + saveImage: async () => { throw new Error('export never saves images') }, + readImage, + } as never) + } + if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) + const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) + const fiber = ctx.plugin(SessionLogExport, { + ...services.compressionLevel === undefined + ? {} + : { compressionLevel: services.compressionLevel }, + }) + await fiber.await() + const handler = connection.createSharedFetchHandler('/api') + return { + fetch: handler, + downloads: { + sessionLog: ( + request: { sessionId: SessionId; includeDescendants: boolean }, + signal: AbortSignal, + ): Promise => { + const url = new URL(`http://host${SessionLogExport.SESSION_LOG_EXPORT_PATH}`) + url.searchParams.set('sessionId', request.sessionId) + url.searchParams.set('includeDescendants', String(request.includeDescendants)) + return handler.fetch(new Request(url, { signal })) + }, + }, + } +} + +function toFetchHandler(api: Awaited>): { fetch(request: Request): Promise } { + return api.fetch +} + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('session export compression config', () => { + it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { + expect(SessionLogExport.Config({})).toEqual({ + compressionLevel: 6, + }) + expect(SessionLogExport.Config({ compressionLevel: 0 })) + .toEqual({ compressionLevel: 0 }) + expect(SessionLogExport.Config({ compressionLevel: 9 })) + .toEqual({ compressionLevel: 9 }) + for (const value of [-1, 10, 1.5]) { + expect(() => SessionLogExport.Config({ compressionLevel: value } as never)).toThrow() + } + }) +}) + +describe('session.export download endpoint', () => { + it('streams a ZIP with the root artifact verbatim under its original filename', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + }) + + it('preflights root preparation through HEAD without streaming a body', async () => { + const readRaw = vi.fn(async () => artifact('session-root')) + const api = await buildApi({}, [], { readRaw }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + expect(response.body).toBeNull() + expect(readRaw).toHaveBeenCalledOnce() + }) + + it('returns a bodyless preparation error from HEAD', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), + ) + + expect(response.status).toBe(404) + expect(response.body).toBeNull() + }) + + it('uses the resolved compression level for ZIP entries', async () => { + const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) + const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) + const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 }) + const stored = await storedApi.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const compressed = await compressedApi.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const storedBytes = await responseBytes(stored) + const compressedBytes = await responseBytes(compressed) + expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) + expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('includes descendant artifacts under subagents// when requested', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'grandchild-a': artifact('grandchild-a', sid('child-a')), + }, [ + node('child-a', node('grandchild-a')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/grandchild-a/session.jsonl', + ]) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + .toBe(artifact('child-a').content) + }) + + it('flushes each live root and descendant immediately before reading its artifact', async () => { + const stored: Record = { + 'session-root': artifact('session-root', undefined, 'stale root'), + 'child-a': artifact('child-a', sid('session-root'), 'stale child'), + } + const durable: Record = { + 'session-root': artifact('session-root', undefined, 'durable root'), + 'child-a': artifact('child-a', sid('session-root'), 'durable child'), + } + const flushed: SessionId[] = [] + const api = await buildApi(stored, [node('child-a')], { + sessions: { + get: id => durable[id] === undefined ? undefined : { id }, + flush: async (session) => { + const artifactAfterFlush = durable[session.id] + if (artifactAfterFlush === undefined) throw new Error('unexpected session') + flushed.push(session.id) + stored[session.id] = artifactAfterFlush + return true + }, + }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(flushed).toEqual([sid('session-root'), sid('child-a')]) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root') + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child') + }) + + it('reads a cold artifact without asking the live-session store to flush', async () => { + const flush = vi.fn(async () => true) + const root = artifact('session-root') + const api = await buildApi({ 'session-root': root }, [], { + sessions: { + get: () => undefined, + flush, + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const files = unzipSync(await responseBytes(response)) + expect(flush).not.toHaveBeenCalled() + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('answers 404 for a missing root session', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(404) + }) + + it('answers 501 when the persistence backend has no per-session raw artifacts', async () => { + const api = await buildApi({}, [], { persistence: 'unsupported' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(501) + expect(await response.text()).toContain('does not expose per-session raw artifacts') + }) + + it('answers 400 when the sessionId query parameter is absent', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?includeDescendants=true'), + ) + expect(response.status).toBe(400) + }) + + it('answers 400 for an includeDescendants value other than true or false', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), + ) + expect(response.status).toBe(400) + }) + + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { + const api = await buildApi({}, [], { query: false, persistence: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('session-query') + }) + + it('fails the whole export when a descendant has no stored artifact', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + }, [node('child-missing')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + // The stream errors before completing, so the body read rejects rather + // than returning a truncated-but-valid archive. + await expect(response.arrayBuffer()).rejects.toThrow() + }) + + it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { + // The push loop slices by 2^16 code units and must back off one unit when + // the boundary lands inside a surrogate pair; otherwise the pair re-encodes + // as U+FFFD and the exported artifact is silently corrupted. + const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + // A boundary that lands on a BMP character needs no surrogate backoff; the + // round trip must still be byte-identical across the multi-chunk push. + const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('waits for response pull capacity before reading the next archive entry', async () => { + const root = artifact('session-root', undefined, [ + imageEventLine('after-root'), + randomBytes(512 * 1024).toString('base64'), + ].join('\n')) + let imageReads = 0 + const api = await buildApi({ 'session-root': root }, [], { + attachments: async (ref) => { + imageReads += 1 + return storedImage(String(ref.attachmentId), ref.mediaType) + }, + }) + vi.useFakeTimers() + let response: Response | undefined + try { + response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + // Exhausting timer turns must not advance a producer whose byte queue is + // full; only a consumer pull can release it. + await vi.runAllTimersAsync() + expect(imageReads).toBe(0) + } finally { + vi.useRealTimers() + } + if (response === undefined) throw new Error('missing export response') + const files = unzipSync(await responseBytes(response)) + expect(imageReads).toBe(1) + expect(files['media/after-root.png']).toEqual(storedImage('after-root').data) + }) + + it('exports an empty artifact as an empty zip entry', async () => { + const root = { ...artifact('session-root'), content: '' } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + }) + + it('exports a shared lineage node once (seen-set dedup)', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'child-b': artifact('child-b', sid('session-root')), + shared: artifact('shared', sid('child-a')), + }, [ + node('child-a', node('shared')), + node('child-b', node('shared')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/child-b/session.jsonl', + 'subagents/shared/session.jsonl', + ]) + }) + + it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + const api = await buildApi({}, [], { query: true, persistence: 'throw' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('answers the private-error-safe 500 when the live root flush fails', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { + sessions: { + get: id => ({ id }), + flush: async () => { throw new Error('/host/private/flush-state') }, + }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to prepare the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('forwards one request signal through root, lineage, and descendant reads', async () => { + const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = [] + const traces: AbortSignal[] = [] + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + reads.push({ id, signal }) + return id === sid('session-root') + ? artifact('session-root') + : artifact('child-a', sid('session-root')) + }, + traceSession: async (_id, signal) => { + if (signal !== undefined) traces.push(signal) + return { + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants: [node('child-a')], + } + }, + }) + const controller = new AbortController() + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + controller.signal, + ) + await response.arrayBuffer() + const rootSignal = reads[0]?.signal + if (rootSignal === undefined) throw new Error('missing root signal') + const producerSignal = traces[0] + if (producerSignal === undefined) throw new Error('missing lineage signal') + expect(reads[0]?.id).toBe(sid('session-root')) + expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal }) + const cancellation = new Error('request cancelled after response') + controller.abort(cancellation) + expect(rootSignal.aborted).toBe(true) + expect(rootSignal.reason).toBe(cancellation) + expect(producerSignal.aborted).toBe(true) + expect(producerSignal.reason).toBe(cancellation) + }) + + it('preserves request cancellation instead of translating it to HTTP 500', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const controller = new AbortController() + const cancellation = new Error('request cancelled') + controller.abort(cancellation) + await expect(api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + controller.signal, + )).rejects.toBe(cancellation) + }) + + it('aborts descendant work and terminates ZIP production when its reader cancels', async () => { + let reportDescendantStarted!: (signal: AbortSignal) => void + const descendantStarted = new Promise((resolve) => { + reportDescendantStarted = resolve + }) + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + if (id === sid('session-root')) return artifact('session-root') + if (signal === undefined) throw new Error('missing descendant signal') + reportDescendantStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const descendantSignal = await descendantStarted + const cancellation = new Error('download consumer left') + await reader.cancel(cancellation) + expect(descendantSignal.aborted).toBe(true) + expect(descendantSignal.reason).toBe(cancellation) + }) + + it('aborts attachment reads when its reader cancels', async () => { + let reportAttachmentStarted!: (signal: AbortSignal) => void + const attachmentStarted = new Promise((resolve) => { + reportAttachmentStarted = resolve + }) + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('slow-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async (_ref, signal) => { + if (signal === undefined) throw new Error('missing attachment signal') + reportAttachmentStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: false }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const attachmentSignal = await attachmentStarted + const cancellation = new Error('download consumer left during attachment read') + await reader.cancel(cancellation) + expect(attachmentSignal.aborted).toBe(true) + expect(attachmentSignal.reason).toBe(cancellation) + }) + + it('uses a stable Error reason when its reader cancels without one', async () => { + let reportDescendantStarted!: (signal: AbortSignal) => void + const descendantStarted = new Promise((resolve) => { + reportDescendantStarted = resolve + }) + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id, signal) => { + if (id === sid('session-root')) return artifact('session-root') + if (signal === undefined) throw new Error('missing descendant signal') + reportDescendantStarted(signal) + return new Promise((_, reject) => { + signal.addEventListener('abort', () => { + reject(signal.reason as Error) + }, { once: true }) + }) + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + const reader = response.body?.getReader() + if (reader === undefined) throw new Error('missing response body') + const descendantSignal = await descendantStarted + await reader.cancel() + expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled')) + }) + + it('normalizes a non-Error descendant failure before erroring the stream', async () => { + const api = await buildApi({}, [node('child-a')], { + readRaw: async (id) => { + if (id === sid('session-root')) return artifact('session-root') + throw 'descendant read failed' + }, + }) + const response = await api.downloads.sessionLog( + { sessionId: sid('session-root'), includeDescendants: true }, + new AbortController().signal, + ) + await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed')) + }) + + it('includes media objects referenced by the root log under media/.', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('img-1'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) + expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) + }) + + it('collects media referenced from nested tool results', async () => { + const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + nested, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) + }) + + it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { + const block = (id: string, mediaType: string) => + `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` + const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` + const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` + const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + wrapped, + inserted, + chunk, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'media/chunk-1.png', + 'media/inserted-1.gif', + 'media/wrapped-1.jpg', + 'session.jsonl', + ]) + }) + + it('deduplicates one media object referenced by several included logs', async () => { + const line = imageEventLine('shared-img') + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + line, + ].join('\n') + '\n') + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + line, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data) + expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png']) + }) + + it('includes descendant media only when descendants are requested', async () => { + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + imageEventLine('child-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) + const without = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) + const withDescendants = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ + 'media/child-img.png', + 'session.jsonl', + 'subagents/child-a/session.jsonl', + ]) + }) + + it('fails the whole export when a referenced image cannot be read', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('gone-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async () => { throw new Error('attachment bytes missing') }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing') + }) + + it('answers 500 when the deployment mounts no attachments service', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('attachments') + }) +}) diff --git a/packages/session-query/session-log-export/tests/command.client.spec.ts b/packages/session-query/session-log-export/tests/command.host.spec.ts similarity index 100% rename from packages/session-query/session-log-export/tests/command.client.spec.ts rename to packages/session-query/session-log-export/tests/command.host.spec.ts diff --git a/packages/session-query/session-log-export/tests/invariant.client.spec.ts b/packages/session-query/session-log-export/tests/invariant.host.spec.ts similarity index 100% rename from packages/session-query/session-log-export/tests/invariant.client.spec.ts rename to packages/session-query/session-log-export/tests/invariant.host.spec.ts diff --git a/packages/session-query/session-log-export/tests/loader-composition.client.spec.ts b/packages/session-query/session-log-export/tests/loader-composition.host.spec.ts similarity index 100% rename from packages/session-query/session-log-export/tests/loader-composition.client.spec.ts rename to packages/session-query/session-log-export/tests/loader-composition.host.spec.ts diff --git a/packages/session-query/session-log-export/tests/route.host.spec.ts b/packages/session-query/session-log-export/tests/route.host.spec.ts index 213ad852a6..44d4b6c476 100644 --- a/packages/session-query/session-log-export/tests/route.host.spec.ts +++ b/packages/session-query/session-log-export/tests/route.host.spec.ts @@ -56,8 +56,7 @@ async function mounted(withServices: boolean): Promise<{ describe('Session log export Fetch route', () => { it('registers one GET/HEAD route and removes it with the plugin fiber', async () => { const { connection, dispose } = await mounted(true) - const fallback = { fetch: async () => new Response('fallback', { status: 418 }) } - const shared = connection.createSharedFetchHandler('/api', fallback) + const shared = connection.createSharedFetchHandler('/api') const response = await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, @@ -76,14 +75,12 @@ describe('Session log export Fetch route', () => { await dispose() expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1`, - ))).status).toBe(418) + ))).status).toBe(404) }) it('validates the query before reporting missing export services', async () => { const { connection, dispose } = await mounted(false) - const shared = connection.createSharedFetchHandler('/api', { - fetch: async () => new Response('fallback', { status: 418 }), - }) + const shared = connection.createSharedFetchHandler('/api') expect((await shared.fetch(new Request(`http://host${SESSION_LOG_EXPORT_PATH}`))).status).toBe(400) expect((await shared.fetch(new Request( `http://host${SESSION_LOG_EXPORT_PATH}?sessionId=session-1&includeDescendants=1`, From e14d354e8392429c5e6213fffeffc8a4404867c4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:51:11 +0800 Subject: [PATCH 112/130] refactor(connection): own RPC transport contracts --- apps/cli/tests/web-auth.e2e.ts | 18 +- apps/web/tests/assembled-boot.ts | 2 +- apps/web/tests/built-boot.expected.e2e.ts | 2 +- .../command-image-envelope.expected.e2e.ts | 2 +- apps/web/tests/goal-bar.e2e.ts | 2 +- apps/web/tests/image-display.expected.e2e.ts | 2 +- .../tests/max-tokens-notice.expected.e2e.ts | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- apps/web/tests/search-card.expected.e2e.ts | 2 +- apps/web/tests/submission-echo.e2e.ts | 2 +- apps/web/tests/todo-row.expected.e2e.ts | 2 +- .../trajectory-image-display.expected.e2e.ts | 2 +- .../api/gateway/tests/gateway.client.spec.ts | 3 +- packages/api/remotes/src/client/index.ts | 4 +- packages/client/connection/package.json | 9 +- packages/client/connection/src/client/api.ts | 47 ++-- .../connection/src/client/connection.ts | 26 +- .../client/connection/src/client/fixture.ts | 127 +++------ .../client/connection/src/client/index.ts | 67 +---- packages/client/connection/src/client/rpc.ts | 2 +- .../connection/src/client/web-api-client.ts | 10 - packages/client/connection/src/index.ts | 30 ++- packages/client/connection/src/invariant.ts | 4 +- packages/client/connection/src/rpc-host.ts | 27 +- packages/client/connection/src/rpc-schema.ts | 53 ++++ packages/client/connection/src/rpc.ts | 107 ++++++++ .../tests/client-apply.client.spec.ts | 117 ++++----- .../tests/connection.client.spec.ts | 245 ++++++++---------- .../connection/tests/fake-api.client.ts | 131 ---------- .../tests/fake-generation.client.ts | 80 ++++++ .../tests/fetch-routes.host.spec.ts | 17 +- .../tests/fixture-commands.client.spec.ts | 2 +- .../connection/tests/fixture.client.spec.ts | 82 ++---- .../connection/tests/node-half.host.spec.ts | 12 +- .../connection/tests/rpc-schema.host.spec.ts | 58 +++++ .../client/connection/tsconfig.client.json | 4 - packages/client/connection/tsconfig.host.json | 6 +- packages/client/tsdown.client.ts | 2 +- .../credentials/authorization/src/types.ts | 2 +- .../webworker-packer/src/rules.ts | 1 - .../webworker-packer/tsconfig.json | 3 + .../webworker-runtime/package.json | 4 +- .../src/client/api-client.ts | 31 --- .../webworker-runtime/src/client/index.ts | 4 - .../webworker-runtime/src/node/builtins.ts | 2 +- .../src/node/external_packages/ws.ts | 2 +- .../webworker-runtime/src/worker-host.ts | 40 +-- .../webworker-runtime/tsconfig.json | 2 +- .../src/client/api-catalog.ts | 28 +- .../src/client/slot-catalog.ts | 4 +- .../interaction/user-approval/src/types.ts | 2 +- scripts/client-bundle-purity.spec.ts | 7 +- 52 files changed, 645 insertions(+), 799 deletions(-) delete mode 100644 packages/client/connection/src/client/web-api-client.ts create mode 100644 packages/client/connection/src/rpc-schema.ts delete mode 100644 packages/client/connection/tests/fake-api.client.ts create mode 100644 packages/client/connection/tests/fake-generation.client.ts create mode 100644 packages/client/connection/tests/rpc-schema.host.spec.ts delete mode 100644 packages/experimental/webworker-runtime/src/client/api-client.ts diff --git a/apps/cli/tests/web-auth.e2e.ts b/apps/cli/tests/web-auth.e2e.ts index b22f611306..05889c8d32 100644 --- a/apps/cli/tests/web-auth.e2e.ts +++ b/apps/cli/tests/web-auth.e2e.ts @@ -119,19 +119,19 @@ async function stopWeb(running: RunningWeb): Promise { clearTimeout(forced) } -/** POST one real API Proxy envelope while controlling the wire Host header. */ -function describeHost(port: number, host: string, cookie?: string): Promise { +/** POST one real Remote envelope while controlling the wire Host header. */ +function describeSettings(port: number, host: string, cookie?: string): Promise { const body = JSON.stringify({ type: 'client-request', rpcId: 'web-auth-real-cli', - method: 'host.describe', - payload: {}, + method: 'settings/describe', + payload: { args: {} }, }) return new Promise((resolve, reject) => { const req = httpRequest({ hostname: '127.0.0.1', port, - path: '/api/host.describe', + path: '/api/settings/describe', method: 'POST', headers: { host, @@ -165,7 +165,7 @@ describe('dsh web authentication through the real CLI', () => { expect(firstUrl.pathname).toBe('/') expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u) - expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({ + expect(await describeSettings(port, `localhost:${String(port)}`)).toEqual({ status: 401, body: 'unauthorized', }) @@ -180,13 +180,13 @@ describe('dsh web authentication through the real CLI', () => { expect(setCookie).not.toContain('Secure') const cookie = setCookie.split(';', 1)[0]! - const authenticated = await describeHost(port, firstUrl.host, cookie) + const authenticated = await describeSettings(port, firstUrl.host, cookie) expect(authenticated.status).toBe(200) const authenticatedBody = JSON.parse(authenticated.body) as unknown expect(authenticatedBody).toMatchObject({ type: 'server-response', rpcId: 'web-auth-real-cli', - result: { ok: true, value: { version: expect.any(String) as unknown } }, + result: { ok: true, value: { namespaces: expect.any(Array) as unknown } }, }) await stopWeb(first) @@ -194,7 +194,7 @@ describe('dsh web authentication through the real CLI', () => { second = await startWeb(root, dshHome, port) const secondUrl = new URL(second.launchUrl) expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token')) - expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200) + expect((await describeSettings(port, secondUrl.host, cookie)).status).toBe(200) const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777 expect(credentialMode).toBe(0o600) diff --git a/apps/web/tests/assembled-boot.ts b/apps/web/tests/assembled-boot.ts index 05bfb45a4b..a18893d82b 100644 --- a/apps/web/tests/assembled-boot.ts +++ b/apps/web/tests/assembled-boot.ts @@ -1,6 +1,6 @@ // Shared scaffolding for the assembled-jsdom snapshots: the real built // workspace `lib/client.js` artifacts booted through AppWebEntry's -// ModuleLoader path (loadBundle) against the keyless FixtureApiClient +// ModuleLoader path (loadBundle) against the keyless fixture Connection RPC // transport. Every file that mounts this graph needs the same boot entry list, // the same bundle map, the same jsdom globals, and the same mount call, and // differs only in what it asserts afterwards, so the scaffolding lives here. diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index cbd5f294a4..275cda21d5 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -4,7 +4,7 @@ // reach a surface only the built bundles expose; this one asserts that the // graph assembles at all — staged activation across the immediately tier and // the inject layers, per-plugin CSS injection, and a rendered journey reaching -// chat content from the keyless FixtureApiClient transport. +// chat content from the keyless fixture Connection RPC. // // Component behavior remains owned by per-package suites (SlotTestRuntime // benches over src). This smoke additionally pins the resident interaction diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index e378072db1..47fb91069b 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // The command image-attachment envelope over the BUILT client graph (real -// bundles via AppWebEntry, keyless FixtureApiClient transport): an enter +// bundles via AppWebEntry, keyless fixture Connection RPC): an enter // submission carrying composer images resolves only through a command whose // descriptor declares `input.images`. A non-declaring command refuses with // one composer error banner and everything retained; a declaring command diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index b9c7695066..f9cd46b60c 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -1,5 +1,5 @@ // Keyless assembled-browser coverage for the goal bar over the shipped Web -// bundles and FixtureApiClient wire. The command creates a real projected +// bundles and the fixture Connection RPC. The command creates a real projected // goal in the fixture session; the golden pins the active strip, while the // clear gesture proves the acknowledged tombstone leaves neither stale chrome // nor a duplicate-mutation error. diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 37be0f24ac..55dcedff13 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Multimodal image surfaces over the BUILT client graph (the code-mode-fixture -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the // history ImageGallery loading real fixture bytes through the authorized diff --git a/apps/web/tests/max-tokens-notice.expected.e2e.ts b/apps/web/tests/max-tokens-notice.expected.e2e.ts index 3e35d13bb8..5634315a32 100644 --- a/apps/web/tests/max-tokens-notice.expected.e2e.ts +++ b/apps/web/tests/max-tokens-notice.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled max-tokens snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless FixtureApiClient transport, opens the fixture session, and pins the +// keyless fixture Connection RPC, opens the fixture session, and pins the // surface its max-tokens turn (72) reaches — the turn-end notice row that a // provider output-cap truncation must render instead of ending silently. // diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 709fb83fca..8f2b62ad29 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -200,7 +200,7 @@ describe('web e2e: fresh round trip through the real assembly', () => { it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) // Interaction over the REAL wire-delivered transcript (the fixture-client - // tier pins the same gesture against FixtureApiClient; this one runs on + // tier pins the same gesture against the fixture Connection RPC; this one runs on // follow-stream-fed state). Runs after the golden capture so the committed // aria surface stays the untouched settled state. await expandTurnProcesses(page) diff --git a/apps/web/tests/search-card.expected.e2e.ts b/apps/web/tests/search-card.expected.e2e.ts index ec8da8ff86..b54d14e8e7 100644 --- a/apps/web/tests/search-card.expected.e2e.ts +++ b/apps/web/tests/search-card.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled search-card snapshot: boots the real built workspace client bundles // through AppWebEntry's ModuleLoader path against the keyless -// FixtureApiClient transport (no API key, no model round), opens the fixture +// fixture Connection RPC (no API key, no model round), opens the fixture // session, and pins the search card the `grep` turn (fixture turn 67) renders in // the assembled application. The built-boot smoke proves the graph boots but // intentionally carries no behavior assertions; this is the assembled-output check diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index 3e53dc5df8..a5d2b5503f 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Local submission echo over the BUILT client graph (keyless FixtureApiClient +// Local submission echo over the BUILT client graph (keyless fixture Connection RPC // transport): a text-plus-image send paints its echo bubble synchronously on // the submit keystroke — before serialization, transport, or the fixture's // durable admission — with the composer already cleared and editable, and the diff --git a/apps/web/tests/todo-row.expected.e2e.ts b/apps/web/tests/todo-row.expected.e2e.ts index 5672dd09a0..21fa70d9bf 100644 --- a/apps/web/tests/todo-row.expected.e2e.ts +++ b/apps/web/tests/todo-row.expected.e2e.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the -// keyless FixtureApiClient transport, opens the fixture session, and pins the +// keyless fixture Connection RPC, opens the fixture session, and pins the // two surfaces the fixture's parallel plan (turn 74, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 07ed9330b2..94607563d8 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // Trajectory image surfaces over the BUILT client graph (the code-mode-fixture -// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the Trajectory surfaces: // selecting the ledger record renders the shared ui-attachment gallery from diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index a77e4a22af..3122fbffd5 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -1809,7 +1809,7 @@ describe('Client Typert API', () => { }) }) - it('publishes the Fixture Host description after Remote events report ready', async () => { + it('publishes the Fixture Host facts after Remote events report ready', async () => { const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') Object.defineProperty(globalThis, 'location', { configurable: true, @@ -1824,7 +1824,6 @@ describe('Client Typert API', () => { if (connection === undefined) throw new Error('fixture Connection service is unavailable') await vi.waitFor(() => { - expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') expect(connection.generation.getSnapshot()?.host.home).toBe('/home/fixture') }) } finally { diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index d40e9f65fc..d449efe704 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -54,8 +54,8 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ConnectionHandle, ConnectionSinks, ContentBlock, IApiClient, - MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + ConnectionHandle, ConnectionSinks, ContentBlock, + MessageId, RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 2be6924acf..07a00ab2de 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: HTTP client, generation lifecycle, and fixture API", + "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -38,7 +38,8 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" }, "files": [ "lib/index.js", @@ -51,7 +52,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -65,7 +66,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 0ae8af9ddf..44e7595f34 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -1,43 +1,26 @@ -// Central contract re-export point: every legacy API contract import inside -// the Connection package goes through this browser-safe file. -// Types and runtime protocol helpers/bounds come from the apiproxy api/ layer -// (zero Node deps, browser-safe); AbstractApiClient is the client boundary. -// NEVER import the package root: it drags bootHost/cordis into the browser bundle. -// The ./api and ./client subpath exports are the browser-safe channels. +/** Browser-safe Connection protocol and shared application value exports. */ export type { - ApiProxy, HostApi, - ResponseValue, - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, -} from '@deepseek-ai/dsh-host-apiproxy/api' -export type { - RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, RpcMessage, -} from '@deepseek-ai/dsh-host-apiproxy/api' -// transportError lives in the apiproxy api layer (beside RpcResult, its -// subject); re-exported here so connection consumers keep one contract -// entry point. -export { - RpcId, - transportError, -} from '@deepseek-ai/dsh-host-apiproxy/api' -export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' -export type { IApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' + ClientRequest, + RpcError, + RpcErrorCode, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + ServerResponse, +} from '../rpc.ts' +export { RpcId, transportError } from '../rpc.ts' export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' export type { MessageId } from '@deepseek-ai/dsh-llm/brand' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' -/** Successful value returned by the connection-generation host handshake. */ -export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'> - -import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { RpcResponse, RpcResult } from '../rpc.ts' /** - * Unwrap a unary response: RpcResponse -> RpcResult (business code only - * cares about the result slot). - * @param response - the unary response. - * @returns its result slot. + * Return the business result carried by a narrow fixture response. + * @param response - fixture response to unwrap. + * @returns the response's business result. */ export function resultOf(response: RpcResponse): RpcResult { return response.result diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index ebcac74ea7..17e946c80e 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,5 +1,3 @@ -import type { HostDescription, IApiClient } from './api.ts' - /** Stable Host facts delivered by one established Remote event generation. */ export interface ConnectionHostInfo { /** Host account home used only to abbreviate displayed filesystem paths. */ @@ -52,8 +50,8 @@ export type ConnectionState = 'connected' | 'reconnecting' /** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { - /** After the generation source is ready and host.describe succeeds, first connect included. */ - onConnected?: (description: HostDescription, host: ConnectionHostInfo) => void + /** After the generation source reports ready, first connect included. */ + onConnected?: (host: ConnectionHostInfo) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ onStateChange?: (state: ConnectionState) => void @@ -86,7 +84,6 @@ export class ConnectionController { private readonly config: Required constructor( - private readonly api: IApiClient, private readonly source: ConnectionGenerationSource, private readonly sinks: ConnectionSinks = {}, config: ConnectionConfig = {}, @@ -173,27 +170,16 @@ export class ConnectionController { }) try { - // The source reports ready only after its incremental listeners exist; - // describe may complete in parallel, but consumers see neither result - // until both sides of the baseline-plus-increment handshake are ready. - const [description, host] = await Promise.race([ - Promise.all([ - this.api.host.describe({}, ac.signal), - waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), - ]), + const host = await Promise.race([ + waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), sourceLost, ]) - const descriptionResult = description.result - if (!descriptionResult.ok) { - throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) - } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 this.emitState('connected') - // A state sink may synchronously stop this controller. Do not publish - // a description for a generation that no longer exists afterward. + // A state sink may synchronously stop this controller. if (this.isGenerationActive(ac)) { - this.callSink(() => { this.sinks.onConnected?.(descriptionResult.value, host) }) + this.callSink(() => { this.sinks.onConnected?.(host) }) } } catch { // Transport failure: treat as generation failure, fall through to the shared backoff. diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 6a5a1017c5..17b3a006c4 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -33,12 +33,7 @@ import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types' import type { DirectoryListing as FixtureDirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import type { SettingsDescribeValue, SettingsNamespaceView } from '@deepseek-ai/dsh-settings/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' -import type { - ApiProxy, ClientRequest, - ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, -} from './api.ts' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' -import { AbstractApiClient, RpcId } from './api.ts' +import type { RpcResult } from './api.ts' import { randomUuid } from './random-uuid.ts' import type { ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, @@ -46,6 +41,26 @@ import type { const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 +interface ModelSelection { + readonly provider: string + readonly model: string + readonly reasoningEffort?: string +} + +interface ModelProviderGroup { + readonly id: string + readonly name: string + readonly models: readonly { + readonly id: string + readonly name: string + readonly description?: string + readonly reasoning?: { + readonly efforts: readonly { readonly id: string; readonly name: string; readonly description?: string }[] + readonly defaultEffort?: string + } + }[] +} + /* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */ function isFixtureTokenDelta(chunk: StreamChunk): boolean { switch (chunk.type) { @@ -324,11 +339,6 @@ interface FixtureWorkspace { updatedAt: string } -/** The fake carrier mints like a real one (business code never mints). */ -function rpcRequest

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(randomUuid()), payload } -} - function text(t: string): ContentBlock[] { return [{ type: 'text', text: t }] } @@ -1731,34 +1741,22 @@ class FxInbox implements StreamConn { } } -/** - * In-memory fake host: fx-alpha carries history and replay scripts; fx-beta is fx-alpha's child session (lineage indent material). - * @param options - fixture branches for empty state and failure timing. - * @returns an ApiProxy backed entirely by in-memory state — no host process, no network. - */ -export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { - return createFixtureWorld(options).api -} - -/** Both fixture faces over one state graph. */ +/** Fixture RPC face over one in-memory state graph. */ export interface FixtureWorld { - /** Legacy unary/stream API the fixture still answers. */ - readonly api: ApiProxy /** Generic Remote caller for the endpoints business services own. */ readonly rpc: ClientConnectionRpc } /** - * Build both fixture faces so a caller can drive the Remote endpoints and the - * legacy API against one in-memory state graph. + * Build the fixture RPC face over one in-memory state graph. * @param options - fixture branches for empty state and failure timing. - * @returns the legacy API face and the Remote RPC face. + * @returns the Remote RPC face. */ export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { return createFixtureWorld(options) } -/** Build the fixture's legacy API and Remote RPC faces over one state graph. */ +/** Build the fixture's Remote RPC face over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. const sessions: FixtureSessionSummary[] = options.empty ? [] : [ @@ -1890,7 +1888,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 - let attachedSessions = options.empty ? 0 : 1 // Workspace entities mirroring the host registry: the fixture sessions all // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId @@ -2015,10 +2012,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { for (const conn of followConns.get(sessionId) ?? []) conn.push(entry) } - /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */ - function ok(request: RpcRequest

    , value: T): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) - } function sessionOk(value: T): Promise> { return Promise.resolve({ ok: true, value }) } @@ -2817,7 +2810,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } sessions.push(created) modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - attachedSessions += 1 const emitSession = (): void => { emitRemote('api-session/added', [created]) } @@ -3399,20 +3391,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, } - const api: ApiProxy = { - host: { - describe: request => ok(request, { - version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, - }), - }, - // Satisfies the ApiProxy contract type only: the browser export button - // hands GET /api/session.export to the native download manager, so this - // stub is never reached through the fixture's dispatch. - downloads: { - sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), - }, - } - const rpc: ClientConnectionRpc = { call(channel, endpoint, payload, signal) { if (channel !== '/api') { @@ -3486,6 +3464,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'credentials/set': return Promise.resolve(credentialRemotes.set(args.ref as string)) case 'credentials/unset': return Promise.resolve(credentialRemotes.unset(args.ref as string)) case 'settings/describe': return Promise.resolve(settingsRemotes.describe()) + case 'settings/canOpenAgentPresetDirectory': return Promise.resolve({ ok: true, value: true }) case 'settings/openSettingsDocument': return Promise.resolve(settingsRemotes.openSettingsDocument()) case 'settings/openAgentPresetDirectory': return Promise.resolve( settingsRemotes.openAgentPresetDirectory(args.agentPreset as string), @@ -3504,6 +3483,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { case 'session/openWorkspacePath': { return sessionOk({ opened: true as const }) } + case 'session/canOpenWorkspacePath': return Promise.resolve({ ok: true, value: true }) case 'session/modelCatalog': return Promise.resolve({ ok: true, value: { @@ -3610,58 +3590,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } }, } - return { api, rpc } + return { rpc } } /** - * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it - * overrides the legacy protocol-level call virtual to dispatch - * straight into the in-memory ApiProxy while still minting rpcIds, fabricating - * the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture - * moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). + * Build the browser fixture transport from the current page's query switches. + * @returns an in-memory Connection RPC transport. */ -export class FixtureApiClient extends AbstractApiClient { - private readonly api: ApiProxy - /** Generic Remote caller backed by the same in-memory state as the legacy fixture API. */ - readonly rpc: ClientConnectionRpc - - constructor() { - super() - const world = createFixtureWorld(fixtureOptionsFromLocation()) - this.api = world.api - this.rpc = world.rpc - } - - protected doFetch(): Promise { - throw new Error('FixtureApiClient overrides all protocol paths; doFetch must be unreachable') - } - - protected override async callUnary( - method: K, - payload: RequestPayload, - signal?: AbortSignal, - ): Promise>> { - void signal - const request = rpcRequest(payload) - const full: ClientRequest = { type: 'client-request', rpcId: request.rpcId, method, payload } - this.onEnvelope(full) - const response = await this.dispatch( - method, - request as RpcRequest, - ) as RpcResponse> - const fullResponse: ServerResponse = { type: 'server-response', rpcId: response.rpcId, result: response.result } - this.onEnvelope(fullResponse) - return response - } - - /** Method-key dispatch into the in-memory contract impl (a real carrier routes by URL path instead). */ - private dispatch( - _method: keyof RpcMethodMap, - request: RpcRequest, - ): Promise> { - return this.api.host.describe(request) - } - +export function createFixtureConnectionRpc(): ClientConnectionRpc { + return createFixtureWorld(fixtureOptionsFromLocation()).rpc } /** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index a03ab1e5d7..36b9025d64 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -3,7 +3,6 @@ * the shared API client, and lets API Gateway own the connection loop. */ import type { Context } from '@deepseek-ai/cordis' -import type { HostDescription, IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, @@ -11,8 +10,7 @@ import { type ConnectionGenerationSource, type ConnectionSinks, } from './connection.ts' -import { FixtureApiClient } from './fixture.ts' -import { WebApiClient } from './web-api-client.ts' +import { createFixtureConnectionRpc } from './fixture.ts' import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' @@ -28,18 +26,15 @@ declare module '@deepseek-ai/cordis' { } } -// ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- +// ---- Browser-safe protocol and shared value re-exports ---- export type { - ApiProxy, HostApi, - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelSelection, + MessageId, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, RpcMessage, - HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + SessionId, SessionEvent, ContentBlock, StreamChunk, } from './api.ts' export { RpcId, - AbstractApiClient, transportError, } from './api.ts' @@ -58,14 +53,6 @@ export type { } from '../rpc.ts' export type { RpcFetch } from './rpc.ts' -/** Observable Host description published by each completed connection handshake. */ -export interface HostDescriptionSource { - /** Latest connected-generation description; absent before connect and while reconnecting. */ - getSnapshot(): HostDescription | undefined - /** Subscribe to description replacement and connection loss. */ - subscribe(listener: () => void): () => void -} - /** Observable identity and Host facts for the active connection generation. */ export interface ConnectionGenerationState { /** Active generation, or undefined before readiness and while reconnecting. */ @@ -84,8 +71,6 @@ export const inject: string[] = [] * provides both halves here instead of forking this plugin. */ export interface ClientTransportHooks { - /** Build the API carrier: unary calls plus the two downstream event streams. */ - createApiClient(): IApiClient /** Transport for generic unary RPC channels (the Typert gateway). */ fetch: RpcFetch /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ @@ -118,16 +103,12 @@ interface ClientTransportGlobal { * Connection stays independent of downstream domain state. */ export interface ConnectionHandle { - /** Shared api client (fixture or real, decided at boot from the page URL). */ - readonly api: IApiClient /** * Whether the privileged surface is reachable: the page authority is * loopback, the transport declares the page owns the Host * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser. */ readonly isLoopback: boolean - /** Generation-scoped Host facts, including the account home and native path-open capability. */ - readonly hostDescription: HostDescriptionSource /** Current Remote event generation and the Host facts carried by its opening frame. */ readonly generation: ConnectionGenerationState /** Generic logical RPC channels over the same Connection transport. */ @@ -162,28 +143,14 @@ interface ConnectionOwner { export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') - const fixtureClient = fixture ? new FixtureApiClient() : undefined + const fixtureRpc = fixture ? createFixtureConnectionRpc() : undefined const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ - const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient() - const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + const rpc = fixtureRpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) let generationSource: ConnectionGenerationSource | undefined let owner: ConnectionOwner | undefined let generationId = 0 let generation: ConnectionGeneration | undefined const generationListeners = new Set<() => void>() - let description: HostDescription | undefined - const descriptionListeners = new Set<() => void>() - const publishDescription = (next: HostDescription | undefined): void => { - if (Object.is(description, next)) return - description = next - for (const listener of [...descriptionListeners]) { - try { - listener() - } catch (error) { - console.error('[connection] host-description listener threw:', error) - } - } - } const publishGeneration = (next: ConnectionGeneration | undefined): void => { if (Object.is(generation, next)) return generation = next @@ -200,18 +167,9 @@ export function apply(ctx: Context): void { owner = undefined current.controller.stop() publishGeneration(undefined) - publishDescription(undefined) } const handle: ConnectionHandle = { - api, isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), - hostDescription: { - getSnapshot: () => description, - subscribe: (listener) => { - descriptionListeners.add(listener) - return () => { descriptionListeners.delete(listener) } - }, - }, generation: { getSnapshot: () => generation, subscribe: (listener) => { @@ -238,24 +196,17 @@ export function apply(ctx: Context): void { if (source === undefined) throw new Error('connection: no generation source is registered') const token = {} const ownsGeneration = (): boolean => owner?.token === token - const controller = new ConnectionController(api, source, { + const controller = new ConnectionController(source, { ...sinks, - onConnected: (next, host) => { + onConnected: (host) => { const nextGeneration = { id: ++generationId, host } publishGeneration(nextGeneration) if (!ownsGeneration() || !Object.is(generation, nextGeneration)) return - publishDescription(next) - // A description subscriber may synchronously stop the loop. In that - // case publishDescription(undefined) has already retracted this - // generation, so do not leak its stale connected notification to - // the consumer sink afterward. - if (!ownsGeneration() || !Object.is(description, next)) return - sinks.onConnected?.(next, host) + sinks.onConnected?.(host) }, onStateChange: (state) => { if (state === 'reconnecting') { publishGeneration(undefined) - publishDescription(undefined) } if (!ownsGeneration()) return sinks.onStateChange?.(state) diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index c7b609c01b..2c3f7d3e28 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -4,7 +4,7 @@ import { RpcId, type ClientRequest, type RpcId as RpcIdType, -} from '@deepseek-ai/dsh-host-apiproxy/api' +} from '../rpc.ts' import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts' import { randomUuid } from './random-uuid.ts' diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts deleted file mode 100644 index 6716f252a5..0000000000 --- a/packages/client/connection/src/client/web-api-client.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Browser API carrier for unary HTTP calls. */ - -import { AbstractApiClient } from './api.ts' - -/** Browser platform subclass supplying fetch for unary calls. */ -export class WebApiClient extends AbstractApiClient { - protected doFetch(input: URL, init?: RequestInit): Promise { - return globalThis.fetch(input, init) - } -} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index c1c1f4d933..34cf79bd65 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -5,7 +5,6 @@ import type {} from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-credentials' // Activates the webServer Context merge used below. import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' -import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' import { assertTrustedAuthority } from './api-request-trust.ts' @@ -14,6 +13,7 @@ import { HostConnectionService } from './rpc-host.ts' export type { ConnectionFetchMethod, + ConnectionFetchHandler, ConnectionFetchRoute, ConnectionIndexRequest, ConnectionIndexResponse, @@ -23,10 +23,22 @@ export type { ConnectionRequestRejection, ConnectionRpcResult, ConnectionTrustRequest, + ClientRequest, HostConnectionHandle, HostConnectionFetch, HostConnectionRpc, + RpcMessage, + ServerResponse, } from './rpc.ts' +export { RpcId, transportError } from './rpc.ts' +export { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from './rpc-schema.ts' export { HostConnectionService } from './rpc-host.ts' export { API_PATH } from './api-path.ts' @@ -51,7 +63,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } } -/** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ +/** Services required before providing Connection. */ export const inject = ['webServer', 'credentials'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -92,19 +104,13 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise ctx.webServer.register(route), 'client-connection: /api route') - ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) }) + ctx.inject(['attachments'], (attachmentCtx) => { + assertImageBodyCapacity(attachmentCtx, maxRequestBodyBytes) + }) } diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 5a187545b5..3b96a053eb 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -18,8 +18,8 @@ export const inject = ['invariants'] * No runtime invariant: browser-session verification reads the credential * record asynchronously at the request that authorizes work, while the * credentials companion owns record commit-event lifetime. Stream/reconnect - * sequencing is exercised directly by behavior specs, rpcId round-trip - * discipline belongs to apiproxy, and route register/dispose symmetry is + * sequencing and rpcId round-trip discipline are exercised directly by + * behavior specs, and route register/dispose symmetry is * audited by the webserver companion. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 92de25729f..a00277d813 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -3,13 +3,11 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { - clientRequestSchema, RpcId, type ClientRequest, - type RpcError, - type RpcErrorDetailsMap, type RpcId as RpcIdType, -} from '@deepseek-ai/dsh-host-apiproxy/api' +} from './rpc.ts' +import { clientRequestSchema } from './rpc-schema.ts' import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' @@ -18,8 +16,10 @@ import type { ConnectionIndexRequest, ConnectionIndexResponse, ConnectionFetchRoute, + ConnectionFetchHandler, HostConnectionFetch, ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, ConnectionRpcHandler, ConnectionRpcResult, ConnectionRequestRejection, @@ -109,15 +109,13 @@ export class HostConnectionService extends Service implements HostConnectionHand } /** - * Compose one shared-channel Fetch handler from its interceptor and fallback. + * Compose one shared-channel Fetch handler from exact routes and its interceptor. * @param channel - shared channel mounted by Connection. - * @param fallback - handler for endpoints not claimed by the interceptor. - * @returns Fetch handler that selects exactly one target for each request. + * @returns Fetch handler that selects one owner or returns 404. */ createSharedFetchHandler( channel: '/api', - fallback: FetchHandler, - ): FetchHandler { + ): ConnectionFetchHandler { return { fetch: (request) => { const pathname = new URL(request.url).pathname @@ -126,7 +124,7 @@ export class HostConnectionService extends Service implements HostConnectionHand const endpoint = endpointFromPath(channel, pathname) const interceptor = this.interceptors.get(channel) if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { - return fallback.fetch(request) + return Promise.resolve(new Response('not found', { status: 404 })) } return interceptor.fetchHandler.fetch(request) }, @@ -248,7 +246,7 @@ function rpcFetchHandler( } } -function invalidEnvelopeResponse(body: unknown, issues: RpcErrorDetailsMap['bad-request']['issues']): Response { +function invalidEnvelopeResponse(body: unknown, issues: readonly object[]): Response { const rawId = (body as { rpcId?: unknown } | null)?.rpcId const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID return errorResponse(rpcId, { @@ -269,7 +267,7 @@ function endpointFromPath(channel: string, pathname: string): string | undefined return endpoint } -function errorResponse(rpcId: RpcIdType, error: RpcError): Response { +function errorResponse(rpcId: RpcIdType, error: ConnectionRpcFailure): Response { return fullResponse(rpcId, { ok: false, error }) } @@ -295,9 +293,4 @@ function assertFetchRoute(route: ConnectionFetchRoute): void { if (methods.size !== route.methods.length) { throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} repeats a method`) } - for (const method of methods) { - if (method !== 'GET' && method !== 'HEAD') { - throw new Error(`connection: exact Fetch route ${JSON.stringify(route.path)} has unsupported method ${JSON.stringify(method)}`) - } - } } diff --git a/packages/client/connection/src/rpc-schema.ts b/packages/client/connection/src/rpc-schema.ts new file mode 100644 index 0000000000..dc919a8187 --- /dev/null +++ b/packages/client/connection/src/rpc-schema.ts @@ -0,0 +1,53 @@ +/** Runtime validation for Connection RPC envelopes. */ + +import { z } from 'zod' +import type { ClientRequest, RpcId, RpcMessage, ServerResponse } from './rpc.ts' + +/** Correlation id after wire validation. */ +export const rpcIdSchema = z.string() as unknown as z.ZodType + +/** Generic endpoint failure carried in a response envelope. */ +export const rpcErrorSchema = z.object({ + code: z.string(), + message: z.string(), + details: z.record(z.string(), z.unknown()), +}) + +/** + * Build the result parser for one endpoint value parser. + * @param value - endpoint-owned success-value parser. + * @returns parser for either a success value or generic failure. + */ +export function rpcResultSchema(value: z.ZodType): z.ZodType<{ + readonly ok: true + readonly value: T +} | { + readonly ok: false + readonly error: z.infer +}> { + return z.union([ + z.object({ ok: z.literal(true), value }), + z.object({ ok: z.literal(false), error: rpcErrorSchema }), + ]) +} + +/** Client request envelope; endpoint payload validation belongs to its owner. */ +export const clientRequestSchema = z.object({ + type: z.literal('client-request'), + rpcId: rpcIdSchema, + method: z.string(), + payload: z.unknown(), +}) as z.ZodType + +/** Server response envelope; endpoint value validation belongs to its caller. */ +export const serverResponseSchema = z.object({ + type: z.literal('server-response'), + rpcId: rpcIdSchema, + result: rpcResultSchema(z.unknown().optional()), +}) as z.ZodType + +/** Either Connection RPC envelope direction. */ +export const rpcMessageSchema = z.discriminatedUnion('type', [ + clientRequestSchema as unknown as z.ZodObject, + serverResponseSchema as unknown as z.ZodObject, +]) as unknown as z.ZodType diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index 9cfb47ab1c..6cbe86d837 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,5 +1,20 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Correlation id minted by a caller and echoed by the Connection response. */ +export type RpcId = Branded<'rpc-id'> + +/** + * Brand one validated string as a Connection correlation id. + * @param id - validated wire identity. + * @returns the same string with the correlation-id brand. + */ +export function RpcId(id: string): RpcId { + return id as RpcId +} + /** Carrier-neutral failure returned by one logical RPC endpoint. */ export interface ConnectionRpcFailure { readonly code: string @@ -12,6 +27,81 @@ export type ConnectionRpcResult = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: ConnectionRpcFailure } +/** Typed failure details used by Client Session adapters. */ +export interface RpcErrorDetailsMap { + 'bad-request': { issues: object[] } + 'cancelled': {} + 'session-not-found': { sessionId: SessionId } + 'invalid-time-zone': { value: string } + 'agent-preset-read-only': { agentPreset: string; reason: string } + 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } + 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } + 'agent-preset-invalid': { agentPreset: string; reason: string } + 'agent-busy': { reason: string } + 'internal': {} +} + +/** Error codes used by Client Session adapters. */ +export type RpcErrorCode = keyof RpcErrorDetailsMap + +/** Typed failure used by Client Session adapters. */ +export type RpcError = { + [Code in RpcErrorCode]: { + readonly code: Code + readonly message: string + readonly details: RpcErrorDetailsMap[Code] + } +}[RpcErrorCode] + +/** Historical short name for a generic Connection result. */ +export type RpcResult = ConnectionRpcResult + +/** + * Convert a rejected transport operation into a generic failure result. + * @param error - rejected transport value. + * @returns an `internal` failure preserving the available message. + */ +export function transportError(error: unknown): RpcResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} + +/** Narrow request form used by direct fixture adapters. */ +export interface RpcRequest

    { + readonly rpcId: RpcId + readonly payload: P +} + +/** Narrow response form used by direct fixture adapters. */ +export interface RpcResponse { + readonly rpcId: RpcId + readonly result: RpcResult +} + +/** Full request envelope carried by Connection RPC transports. */ +export interface ClientRequest { + readonly type: 'client-request' + readonly rpcId: RpcId + readonly method: string + readonly payload: unknown +} + +/** Full response envelope carried by Connection RPC transports. */ +export interface ServerResponse { + readonly type: 'server-response' + readonly rpcId: RpcId + readonly result: ConnectionRpcResult +} + +/** Complete Connection RPC envelope union. */ +export type RpcMessage = ClientRequest | ServerResponse + /** HTTP request facts consumed by browser trust and authentication. */ export interface ConnectionTrustRequest { /** Request headers supplied by either the Fetch or node:http representation. */ @@ -100,6 +190,13 @@ export interface HostConnectionHandle { /** Exact Fetch routes for streaming or browser-native responses. */ readonly fetch: HostConnectionFetch + /** + * Compose exact Fetch routes and the shared-channel RPC interceptor. + * @param channel - shared channel mounted by Connection. + * @returns Fetch handler for trusted, authenticated requests. + */ + createSharedFetchHandler(channel: '/api'): ConnectionFetchHandler + /** * Apply Connection's Host/Origin checks and browser authentication to * another Web route. @@ -124,6 +221,16 @@ export interface HostConnectionHandle { authenticatedUrl(baseUrl: string): string } +/** Transport-independent Fetch handler used by HTTP and worker carriers. */ +export interface ConnectionFetchHandler { + /** + * Dispatch one already-authenticated request. + * @param request - Fetch request below the shared channel. + * @returns the registered response or a 404 response. + */ + fetch(request: Request): Promise +} + /** Client caller for logical RPC channels carried by the current transport. */ export interface ClientConnectionRpc { /** diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index fb5257e355..e317c78f0d 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -10,8 +10,6 @@ import { type ConnectionGenerationSource, type ConnectionHandle, } from '../src/client/index.ts' -import { FixtureApiClient } from '../src/client/fixture.ts' -import { WebApiClient } from '../src/client/web-api-client.ts' type Win = { location?: { hostname: string; search: string; origin?: string } @@ -61,20 +59,22 @@ async function mount(): Promise { } describe('connection client apply', () => { - it('mounts ctx.connection with the real client when no ?fixture switch is present', async () => { + it('treats a runtime without browser location as local', async () => { + delete (globalThis as Win).location + expect((await mount()).isLoopback).toBe(true) + }) + + it('mounts ctx.connection and identifies a loopback page', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() - expect(handle.api).toBeInstanceOf(WebApiClient) expect(handle.isLoopback).toBe(true) }) - it('selects the fixture client under ?fixture (and with no location at all stays real)', async () => { + it('selects the fixture RPC transport under ?fixture', async () => { ;(globalThis as Win).location = { hostname: '127.0.0.1', search: '?fixture' } - expect((await mount()).api).toBeInstanceOf(FixtureApiClient) - delete (globalThis as Win).location const handle = await mount() - expect(handle.api).toBeInstanceOf(WebApiClient) - expect(handle.isLoopback).toBe(true) + await expect(handle.rpc.call('/api', 'settings/describe', { args: {} })) + .resolves.toMatchObject({ ok: true }) }) it('reports non-loopback page authority through the connection handle', async () => { @@ -98,10 +98,10 @@ describe('connection client apply', () => { const loop = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) unregisterSecond() - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() loop.stop() }) @@ -110,26 +110,26 @@ describe('connection client apply', () => { const handle = await mount() installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const descriptions: Array = [] - const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') }) - const stopDescription = handle.hostDescription.subscribe(() => { - descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + const generations: Array = [] + const stopThrowing = handle.generation.subscribe(() => { throw new Error('subscriber bug') }) + const stopGeneration = handle.generation.subscribe(() => { + generations.push(handle.generation.getSnapshot()?.host.home) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() // config omitted: the `config ?? {}` default arm is part of the surface. let connected = 0 const loop = handle.start({ onConnected: () => { connected++ } }) expect(() => handle.start({})).toThrow(/already owned by another consumer/) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) loop.stop() // teardown must not throw; the fixture streams abort quietly - expect(handle.hostDescription.getSnapshot()).toBeUndefined() - expect(descriptions).toEqual([true, undefined]) + expect(handle.generation.getSnapshot()).toBeUndefined() + expect(generations).toEqual(['/h', undefined]) expect(connected).toBe(1) expect(errorSpy).toHaveBeenCalledTimes(2) stopThrowing() - stopDescription() + stopGeneration() errorSpy.mockRestore() }) @@ -140,87 +140,87 @@ describe('connection client apply', () => { const first = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) first.stop() - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() const second = handle.start({}) await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) first.stop() - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') second.stop() generation.end() }) - it('does not announce a generation synchronously stopped by a description subscriber', async () => { + it('does not announce a generation synchronously stopped by a generation subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() installGeneration(handle) const owner: { loop?: ReturnType } = {} - let sawDescription = false - const stopDescription = handle.hostDescription.subscribe(() => { - if (handle.hostDescription.getSnapshot() === undefined) return - sawDescription = true + let sawGeneration = false + const stopGeneration = handle.generation.subscribe(() => { + if (handle.generation.getSnapshot() === undefined) return + sawGeneration = true owner.loop?.stop() }) const connected = vi.fn() const loop = handle.start({ onConnected: connected }) owner.loop = loop try { - await vi.waitFor(() => { expect(sawDescription).toBe(true) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + await vi.waitFor(() => { expect(sawGeneration).toBe(true) }) + expect(handle.generation.getSnapshot()).toBeUndefined() expect(connected).not.toHaveBeenCalled() } finally { - stopDescription() + stopGeneration() loop.stop() } }) - it('retracts the host description while reconnecting and republishes the next generation', async () => { + it('retracts the generation while reconnecting and publishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) - const descriptions: Array = [] - const reconnectSnapshots: Array = [] - const stopDescription = handle.hostDescription.subscribe(() => { - descriptions.push(handle.hostDescription.getSnapshot()?.canOpenPath) + const generations: Array = [] + const reconnectSnapshots: Array = [] + const stopGeneration = handle.generation.subscribe(() => { + generations.push(handle.generation.getSnapshot()?.host.home) }) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const loop = handle.start({ onStateChange: (state) => { if (state === 'reconnecting') { - reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) + reconnectSnapshots.push(handle.generation.getSnapshot()?.host.home) } }, }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) generation.end() await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) - await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + await vi.waitFor(() => { expect(generations).toEqual(['/h', undefined, '/h']) }) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') } finally { - stopDescription() + stopGeneration() loop.stop() warnSpy.mockRestore() } }) - it('does not announce reconnecting after a description subscriber stops the loop', async () => { + it('does not announce reconnecting after a generation subscriber stops the loop', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const generation = installGeneration(handle) const owner: { loop?: ReturnType } = {} let stoppedOnRetraction = false - const stopDescription = handle.hostDescription.subscribe(() => { - if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return + const stopGeneration = handle.generation.subscribe(() => { + if (handle.generation.getSnapshot() !== undefined || owner.loop === undefined) return stoppedOnRetraction = true owner.loop.stop() }) @@ -232,38 +232,20 @@ describe('connection client apply', () => { owner.loop = loop try { await vi.waitFor(() => { - expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + expect(handle.generation.getSnapshot()?.host.home).toBe('/h') }) generation.end() await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) }) - expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(handle.generation.getSnapshot()).toBeUndefined() expect(states).toEqual(['connected']) } finally { - stopDescription() + stopGeneration() loop.stop() warnSpy.mockRestore() } }) - it('WebApiClient keeps unary calls on globalThis.fetch', async () => { - ;(globalThis as Win).location = { hostname: 'localhost', search: '' } - const handle = await mount() - const original = globalThis.fetch - const seen: string[] = [] - globalThis.fetch = (input: URL | RequestInfo) => { - seen.push(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url) - return Promise.resolve(new Response('{}', { status: 200 })) - } - try { - // Schema rejection is fine — the transport hop is the assertion. - await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) - } finally { - globalThis.fetch = original - } - expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) - }) - it('carries RPC calls without requiring secure-context randomUUID', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } vi.stubGlobal('crypto', { @@ -311,7 +293,6 @@ describe('connection client apply', () => { })(), ) ;(globalThis as Win).__DSH_TRANSPORT__ = { - createApiClient: () => new FixtureApiClient(), fetch: vi.fn(), openStream, ownsHost: true, @@ -428,7 +409,7 @@ describe('connection client apply', () => { } }) - it('carries Goal Remotes over the same state as the client-only fixture API', async () => { + it('carries Goal Remotes over the client-only fixture state', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() const created = await handle.rpc.call('/api', 'goals/create', { diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 40f9a5ec6c..9ac090e947 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -1,117 +1,52 @@ -/** - * ConnectionController: strict readiness handshake (describe + incremental - * source ready), generation - * abort on loss, backoff reconnection, state transitions, and sink-exception - * isolation. Real (short) timers — the timeout and backoff are configurable, - * so tests run them at millisecond scale. - */ +/** Connection generation readiness, loss, retry, and sink isolation. */ import { describe, expect, it, vi } from 'vitest' -import type { ConnectionState } from '../src/client/connection.ts' +import type { ConnectionGenerationSource, ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' -import { FakeApiClient, deferred, ok } from './fake-api.client.ts' +import { FakeGenerationSource } from './fake-generation.client.ts' const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { - it('announces connected after describe plus generation readiness', async () => { - const api = new FakeApiClient() - const descriptions: boolean[] = [] - let connected = 0 - const controller = new ConnectionController(api, api.generation, { - onConnected: (description) => { - connected++ - descriptions.push(description.canOpenPath) - }, + it('announces connected with the Host facts from generation readiness', async () => { + const source = new FakeGenerationSource() + const homes: string[] = [] + const controller = new ConnectionController(source.source, { + onConnected: (host) => { homes.push(host.home) }, }, FAST) controller.start() try { - await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.callsOf('host.describe')).toHaveLength(1) - expect(descriptions).toEqual([true]) + await vi.waitFor(() => { expect(homes).toEqual(['/h']) }) } finally { controller.stop() } }) it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.failStreams(new Error('stream torn')) - await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff - expect(api.openGenerationCount).toBe(1) + source.fail(new Error('stream torn')) + await vi.waitFor(() => { expect(connected).toBe(2) }) + expect(source.activeCount).toBe(1) } finally { controller.stop() warnSpy.mockRestore() } - // stop() aborts the live generation and no reconnect follows. - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) await new Promise(resolve => setTimeout(resolve, 40)) - expect(api.openGenerationCount).toBe(0) - }) - - it('treats describe failure as generation failure and retries', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls === 1 ? Promise.reject(new Error('host down')) : gate.promise - } - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff - expect(connected).toBe(0) // never announced during the failed generation - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - await vi.waitFor(() => { expect(connected).toBe(1) }) - } finally { - controller.stop() - warnSpy.mockRestore() - } - }) - - it('treats a host.describe business error as generation failure', async () => { - const api = new FakeApiClient() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls += 1 - if (describeCalls === 1) { - return Promise.resolve({ - rpcId: 'bad-describe' as never, - result: { - ok: false as const, - error: { code: 'internal' as const, message: 'not ready', details: {} }, - }, - }) - } - return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - } - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) - await vi.waitFor(() => { expect(connected).toBe(1) }) - } finally { - controller.stop() - warnSpy.mockRestore() - } + expect(source.activeCount).toBe(0) }) it('isolates a connected sink exception from the generation', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() let connected = 0 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ throw new Error('business layer bug') @@ -120,7 +55,7 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openGenerationCount).toBe(1) + expect(source.activeCount).toBe(1) expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error)) } finally { controller.stop() @@ -128,47 +63,75 @@ describe('connection lifecycle', () => { } }) - it('holds onConnected until the incremental source is ready after describe succeeds', async () => { - const api = new FakeApiClient() - api.holdGenerationReady = true + it('holds onConnected until the incremental source reports ready', async () => { + const source = new FakeGenerationSource() + source.holdReady = true let connected = 0 - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) await new Promise(resolve => setTimeout(resolve, 30)) - expect(connected).toBe(0) // describe alone must not announce - api.releaseGenerationReady() + expect(connected).toBe(0) + source.releaseReady() await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() } }) - it('rejects a generation whose source ends during readiness and retries', async () => { - const api = new FakeApiClient() - const firstDescribe = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls === 1 - ? firstDescribe.promise - : Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) + it('accepts only the first readiness report from one generation', async () => { + const homes: string[] = [] + const source: ConnectionGenerationSource = (signal, ready) => { + ready({ home: '/first' }) + ready({ home: '/duplicate' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) } + const controller = new ConnectionController(source, { + onConnected: (host) => { homes.push(host.home) }, + }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(homes).toEqual(['/first']) }) + } finally { + controller.stop() + } + }) + + it('does not announce readiness after a stop queued from the ready callback', async () => { + const owner: { controller?: ConnectionController } = {} + let sourceCalls = 0 + const connected = vi.fn() + const source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + sourceCalls++ + ready({ home: '/h' }) + queueMicrotask(() => { owner.controller?.stop() }) + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + const controller = new ConnectionController(source, { onConnected: connected }, FAST) + owner.controller = controller + controller.start() + await vi.waitFor(() => { expect(sourceCalls).toBe(1) }) + expect(connected).not.toHaveBeenCalled() + }) + + it('rejects a generation whose source ends during readiness and retries', async () => { + const source = new FakeGenerationSource() + source.holdReady = true const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) }) - api.endStreams() - firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) - - await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) + source.holdReady = false + source.end() await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['reconnecting', 'connected']) } finally { @@ -181,22 +144,21 @@ describe('connection lifecycle', () => { { label: 'ends normally', fail: () => Promise.resolve() }, { label: 'rejects with a non-Error reason', - // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario. fail: () => Promise.reject('fixture offline'), }, ])('retries when the generation source $label before reporting ready', async ({ fail }) => { - const api = new FakeApiClient() let sourceCalls = 0 let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, (signal, ready) => { + const source: ConnectionGenerationSource = (signal, ready) => { sourceCalls++ if (sourceCalls === 1) return fail() ready({ home: '/h' }) return new Promise((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) }) - }, { onConnected: () => { connected++ } }, FAST) + } + const controller = new ConnectionController(source, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(sourceCalls).toBe(2) }) @@ -208,19 +170,19 @@ describe('connection lifecycle', () => { }) it('rejects and retries a generation whose source never reports ready', async () => { - const api = new FakeApiClient() - api.suppressGenerationReady = true + const source = new FakeGenerationSource() + source.suppressReady = true let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const controller = new ConnectionController( - api, - api.generation, + source.source, { onConnected: () => { connected++ } }, { ...FAST, generationReadyTimeoutMs: 20 }, ) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) }) + await vi.waitFor(() => { expect(source.activeCount).toBeGreaterThan(0) }) + await new Promise(resolve => setTimeout(resolve, 45)) expect(connected).toBe(0) } finally { controller.stop() @@ -229,11 +191,11 @@ describe('connection lifecycle', () => { }) it('emits deduplicated connected/reconnecting state transitions', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -241,7 +203,7 @@ describe('connection lifecycle', () => { try { await vi.waitFor(() => { expect(connected).toBe(1) }) expect(states).toEqual(['connected']) - api.failStreams(new Error('torn')) + source.fail(new Error('torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) expect(states).toEqual(['connected', 'reconnecting', 'connected']) } finally { @@ -251,10 +213,10 @@ describe('connection lifecycle', () => { }) it('does not announce a generation stopped synchronously by its connected state sink', async () => { - const api = new FakeApiClient() + const source = new FakeGenerationSource() const states: ConnectionState[] = [] let connected = 0 - const controller = new ConnectionController(api, api.generation, { + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ }, onStateChange: (state) => { states.push(state) @@ -264,59 +226,58 @@ describe('connection lifecycle', () => { controller.start() await vi.waitFor(() => { expect(states).toEqual(['connected']) }) - await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) + await vi.waitFor(() => { expect(source.activeCount).toBe(0) }) expect(connected).toBe(0) }) it('deduplicates consecutive reconnecting emissions across two straight failures', async () => { - const api = new FakeApiClient() - const gate = deferred>>() - let describeCalls = 0 - api.onDescribe = () => { - describeCalls++ - return describeCalls <= 2 ? Promise.reject(new Error('down')) : gate.promise - } + let sourceCalls = 0 const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, api.generation, { + const source: ConnectionGenerationSource = (signal, ready) => { + sourceCalls++ + if (sourceCalls <= 2) return Promise.reject(new Error('down')) + ready({ home: '/h' }) + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + const controller = new ConnectionController(source, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(describeCalls).toBe(3) }) - gate.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) + await vi.waitFor(() => { expect(sourceCalls).toBe(3) }) await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(states).toEqual(['reconnecting', 'connected']) // two failures, one reconnecting emission + expect(states).toEqual(['reconnecting', 'connected']) } finally { controller.stop() warnSpy.mockRestore() } }) - it('runs with no sinks at all (every callback slot optional)', async () => { - const api = new FakeApiClient() - const controller = new ConnectionController(api, api.generation, {}, FAST) + it('runs with no sinks at all', async () => { + const source = new FakeGenerationSource() + const controller = new ConnectionController(source.source, {}, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) - await new Promise(resolve => setTimeout(resolve, 20)) + await vi.waitFor(() => { expect(source.activeCount).toBe(1) }) } finally { controller.stop() } }) - it('start() is idempotent (one loop, one stream set)', async () => { - const api = new FakeApiClient() + it('start() is idempotent', async () => { + const source = new FakeGenerationSource() let connected = 0 - const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(source.source, { onConnected: () => { connected++ } }, FAST) controller.start() controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openGenerationCount).toBe(1) - expect(api.callsOf('host.describe')).toHaveLength(1) + expect(source.activeCount).toBe(1) } finally { controller.stop() } diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts deleted file mode 100644 index 639f52f2b9..0000000000 --- a/packages/client/connection/tests/fake-api.client.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo -// data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). The generation source is a hand pump. -import type { IApiClient, RpcResponse } from '../src/client/api.ts' -import type { ConnectionGenerationSource } from '../src/client/connection.ts' -import { RpcId } from '../src/client/api.ts' - -export interface Deferred { - promise: Promise - resolve(value: T): void - reject(error: unknown): void -} - -/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ -export function deferred(): Deferred { - let resolve!: (value: T) => void - let reject!: (error: unknown) => void - const promise = new Promise((res, rej) => { - resolve = res - reject = rej - }) - return { promise, resolve, reject } -} - -let nextRpc = 0 - -export function ok(value: T): RpcResponse { - return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } -} - - -type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } - -interface StreamConn { - feed(item: StreamItem): void -} - -export class FakeApiClient implements IApiClient { - /** Chronological call record: [method, payload]. */ - readonly calls: { method: string; payload: unknown }[] = [] - - // Programmable slots (defaults answer OK-empty); reassign per case. - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - - private readonly generationConns: StreamConn[] = [] - - readonly host: IApiClient['host'] = { - describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), - } - - /** When true, the source never reports ready. */ - suppressGenerationReady = false - - /** When true, ready callbacks remain parked until the test releases them. */ - holdGenerationReady = false - private heldOpens: (() => void)[] = [] - - releaseGenerationReady(): void { - const held = this.heldOpens - this.heldOpens = [] - for (const fire of held) fire() - } - - readonly generation: ConnectionGenerationSource = (signal, ready) => - this.openGeneration(signal, ready) - - /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ - endStreams(): void { - for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' }) - } - - failStreams(error: unknown): void { - for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error }) - } - - get openGenerationCount(): number { - return this.generationConns.length - } - - callsOf(method: string): unknown[] { - return this.calls.filter(c => c.method === method).map(c => c.payload) - } - - private record(method: string, payload: unknown, response: Promise): Promise { - this.calls.push({ method, payload }) - return response - } - - private async openGeneration( - signal: AbortSignal, - onOpen: (host: { readonly home: string }) => void, - ): Promise { - const inbox: StreamItem[] = [] - let wake: (() => void) | null = null - const conn: StreamConn = { - feed: (item) => { - inbox.push(item) - wake?.() - }, - } - this.generationConns.push(conn) - const ready = (): void => { onOpen({ home: '/h' }) } - if (this.holdGenerationReady) this.heldOpens.push(ready) - else if (!this.suppressGenerationReady) ready() - try { - while (!signal.aborted) { - while (inbox.length > 0) { - const item = inbox.shift() as StreamItem - if (item.kind === 'end') return - if (item.kind === 'fail') throw item.error - } - await new Promise((resolve) => { - wake = resolve - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) - wake = null - } - } finally { - this.generationConns.splice(this.generationConns.indexOf(conn), 1) - } - } -} diff --git a/packages/client/connection/tests/fake-generation.client.ts b/packages/client/connection/tests/fake-generation.client.ts new file mode 100644 index 0000000000..83625bb55e --- /dev/null +++ b/packages/client/connection/tests/fake-generation.client.ts @@ -0,0 +1,80 @@ +/** Test-local programmable Connection generation source. */ +import type { ConnectionGenerationSource } from '../src/client/connection.ts' + +type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } + +interface StreamConnection { + feed(item: StreamItem): void +} + +/** Hand-pumped generation source for Connection lifecycle tests. */ +export class FakeGenerationSource { + private readonly connections: StreamConnection[] = [] + + /** When true, the source never reports ready. */ + suppressReady = false + + /** When true, ready callbacks remain parked until the test releases them. */ + holdReady = false + + private heldReady: Array<() => void> = [] + + /** Open one generation. */ + readonly source: ConnectionGenerationSource = (signal, ready) => this.open(signal, ready) + + /** Release every generation currently parked before readiness. */ + releaseReady(): void { + const held = this.heldReady + this.heldReady = [] + for (const fire of held) fire() + } + + /** End every active generation normally. */ + end(): void { + for (const connection of [...this.connections]) connection.feed({ kind: 'end' }) + } + + /** Fail every active generation. */ + fail(error: unknown): void { + for (const connection of [...this.connections]) connection.feed({ kind: 'fail', error }) + } + + /** Number of currently active generations. */ + get activeCount(): number { + return this.connections.length + } + + private async open( + signal: AbortSignal, + onReady: (host: { readonly home: string }) => void, + ): Promise { + const inbox: StreamItem[] = [] + let wake: (() => void) | null = null + const connection: StreamConnection = { + feed: (item) => { + inbox.push(item) + wake?.() + }, + } + this.connections.push(connection) + const ready = (): void => { onReady({ home: '/h' }) } + if (this.holdReady) this.heldReady.push(ready) + else if (!this.suppressReady) ready() + try { + while (!signal.aborted) { + while (inbox.length > 0) { + const item = inbox.shift() as StreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + wake = null + } + } finally { + this.connections.splice(this.connections.indexOf(connection), 1) + } + } +} diff --git a/packages/client/connection/tests/fetch-routes.host.spec.ts b/packages/client/connection/tests/fetch-routes.host.spec.ts index 7c83fed1b5..d5dbee979e 100644 --- a/packages/client/connection/tests/fetch-routes.host.spec.ts +++ b/packages/client/connection/tests/fetch-routes.host.spec.ts @@ -19,17 +19,16 @@ async function mounted(): Promise<{ } describe('Connection exact Fetch routes', () => { - it('dispatches owned methods before the transitional fallback', async () => { + it('dispatches owned methods and returns 404 for unclaimed requests', async () => { const { connection, dispose: disposeFiber } = await mounted() const route = vi.fn(async (request: Request) => Response.json({ query: new URL(request.url).searchParams.get('sessionId') })) - const fallback = vi.fn(async () => new Response('fallback', { status: 418 })) const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'HEAD'], fetch: route, }) - const shared = connection.createSharedFetchHandler('/api', { fetch: fallback }) + const shared = connection.createSharedFetchHandler('/api') const response = await shared.fetch(new Request( 'http://host/api/session.export?sessionId=session-1', @@ -37,16 +36,12 @@ describe('Connection exact Fetch routes', () => { expect(response.status).toBe(200) expect(await response.json()).toEqual({ query: 'session-1' }) expect(route).toHaveBeenCalledOnce() - expect(fallback).not.toHaveBeenCalled() - const post = await shared.fetch(new Request('http://host/api/session.export', { method: 'POST' })) - expect(post.status).toBe(418) - expect(fallback).toHaveBeenCalledOnce() + expect(post.status).toBe(404) await dispose() const withdrawn = await shared.fetch(new Request('http://host/api/session.export')) - expect(withdrawn.status).toBe(418) - expect(fallback).toHaveBeenCalledTimes(2) + expect(withdrawn.status).toBe(404) await disposeFiber() }) @@ -61,10 +56,6 @@ describe('Connection exact Fetch routes', () => { expect(() => connection.fetch.register({ path: '/api/session.export', methods: ['GET', 'GET'], fetch, })).toThrow('repeats a method') - expect(() => connection.fetch.register({ - path: '/api/session.export', methods: ['POST' as 'GET'], fetch, - })).toThrow('unsupported method') - const dispose = connection.fetch.register({ path: '/api/session.export', methods: ['GET'], fetch, }) diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 818e34d92a..ea5c3a764b 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -169,7 +169,7 @@ describe('createFixtureApi commands/skills', () => { }) }) -describe('FixtureApiClient command/skill dispatch', () => { +describe('fixture Connection command/skill dispatch', () => { it('routes the Remote command and skill rows through one state graph', async () => { const { rpc } = createFixtureFaces() const commands = await callRemote<{ name: string }[]>(rpc, 'commands/list', { agentId: sid('fx-alpha') }) diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index 8e41867de0..b7e76ec767 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,7 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { - ModelSelection, - RpcMessage, RpcRequest, RpcResponse, RpcResult, @@ -12,7 +10,7 @@ import { RpcId } from '../src/client/api.ts' import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows' import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import { - FixtureApiClient, + createFixtureConnectionRpc, createFixtureFaces, type FixtureOptions, } from '../src/client/fixture.ts' @@ -21,6 +19,7 @@ import type { } from '../src/rpc.ts' import type { DirectoryListing } from '@deepseek-ai/dsh-host-directory-picker/types' import type { ModelCatalog } from '@deepseek-ai/dsh-api-session-controller/types' +import type { ModelSelection } from '@deepseek-ai/dsh-api-session-controller/types' const sid = (id: string): SessionId => id as SessionId type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } @@ -288,7 +287,7 @@ interface FixtureRemoteEventStream extends AsyncIterable } -type FixtureTestApi = ReturnType['api'] & { +type FixtureTestApi = { /** The directory-picking Remote namespace as the fixture serves it. */ readonly directoryPickerRemote: { pick: () => Promise> @@ -307,8 +306,8 @@ type FixtureTestApi = ReturnType['api'] & { /** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { - const { api, rpc } = createFixtureFaces(options) - return Object.assign(api, { + const { rpc } = createFixtureFaces(options) + return { directoryPickerRemote: { pick: () => rpc.call('/api', 'directoryPicker/pick', { args: {} }) as Promise>, @@ -327,7 +326,7 @@ function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), answerRemoteEvent: (result: FixtureRemoteEventResult) => rpc.call('/api', '$events/result', { args: result }), - }) + } } /** The fixture's Credentials Remote endpoints over the shared RPC carrier. */ @@ -1103,16 +1102,6 @@ describe('createFixtureApi', () => { expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) - it('describe answers the fixture identity', async () => { - const api = createFixtureApi() - const response = await api.host.describe(req({})) - expect(response.result).toMatchObject({ - ok: true, value: { version: '0.0.0-fixture', attachedSessions: 1, home: '/home/fixture' }, - }) - const empty = await createFixtureApi({ empty: true }).host.describe(req({})) - expect(empty.result).toMatchObject({ ok: true, value: { attachedSessions: 0 } }) - }) - it('createDirectory under the root mints /name whose listing and crumbs share the identity', async () => { const api = createFixtureApi() const created = await api.directoryPickerRemote.createDirectory('/', 'srv') @@ -1613,38 +1602,16 @@ describe('createFixtureApi', () => { }) }) -describe('FixtureApiClient (protocol-level fake carrier)', () => { +describe('fixture Connection RPC', () => { afterEach(() => { vi.restoreAllMocks() vi.unstubAllGlobals() }) - it('doFetch is an unreachable tripwire (all protocol paths overridden)', () => { - const client = new FixtureApiClient() - // Protected at compile time only; reach it directly to pin the tripwire message. - expect(() => (client as unknown as { doFetch(): Promise }).doFetch()).toThrow(/doFetch must be unreachable/) - }) - - it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => { - const client = new FixtureApiClient() - const tapped: RpcMessage[] = [] - client.subscribeEnvelopes(batch => tapped.push(...batch)) - const response = await client.host.describe({}) - expect(response.result.ok).toBe(true) - await vi.waitFor(() => { - const kinds = tapped.map(m => m.type) - expect(kinds).toContain('client-request') - expect(kinds).toContain('server-response') - }) - const request = tapped.find(m => m.type === 'client-request') - const reply = tapped.find(m => m.type === 'server-response') - expect(request?.rpcId).toBe(reply?.rpcId) // echo discipline holds through the fake carrier - }) - - it('covers the whole unary dispatch table', async () => { - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) - const workspaces = createWorkspaceClient(client.rpc) + it('covers the migrated Remote dispatch table', async () => { + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) + const workspaces = createWorkspaceClient(rpc) expect((await sessions.search( { query: 'fixture' }, new AbortController().signal, @@ -1655,8 +1622,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await sessions.history({ sessionId: id })).result.ok).toBe(true) expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true) - expect((await client.host.describe({})).result.ok).toBe(true) - expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0) + expect((await readWorkspaceBaseline(createWorkspaceRemote(rpc))).items).not.toHaveLength(0) const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') @@ -1672,13 +1638,13 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) it('folds the goal lifecycle over the Goal Remotes', async () => { - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) const created = await sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId const goal = (endpoint: string, args: Record) => - client.rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) + rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) // create → edit → pause → resume → complete → clear; each mutation advances the CAS // revision by one (state rides the projection frames). @@ -1717,17 +1683,17 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { vi.stubGlobal('location', { search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', }) - const client = new FixtureApiClient() - const sessions = createSessionClient(client.rpc) - const workspaces = createWorkspaceClient(client.rpc) - const workspaceRemote = createWorkspaceRemote(client.rpc) + const rpc = createFixtureConnectionRpc() + const sessions = createSessionClient(rpc) + const workspaces = createWorkspaceClient(rpc) + const workspaceRemote = createWorkspaceRemote(rpc) await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const hostAbort = new AbortController() const workspaceAbort = new AbortController() const hostFrames = collectValues( - openFixtureRemoteEvents(client.rpc, hostAbort.signal), + openFixtureRemoteEvents(rpc, hostAbort.signal), hostAbort, frames => frames.length === 1, ) @@ -1761,8 +1727,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('maps attach-failure and dropped-response query scenarios', async () => { vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) - const partial = new FixtureApiClient() - const partialResult = await createSessionClient(partial.rpc).create({ + const partial = createFixtureConnectionRpc() + const partialResult = await createSessionClient(partial).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), }) @@ -1772,8 +1738,8 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) - const dropped = new FixtureApiClient() - await expect(createSessionClient(dropped.rpc).create({ + const dropped = createFixtureConnectionRpc() + await expect(createSessionClient(dropped).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), })).rejects.toThrow(/dropped session\.create response/) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 4f4fc17e39..268b37e75c 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -6,11 +6,9 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' -import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' +import { API_PATH, RpcId, apply, inject, type ClientRequest, type HostConnectionHandle } from '../src/index.ts' import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' import { provideBrowserCredentials } from './browser-credentials.ts' @@ -94,7 +92,6 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ const upgrades: WebUpgradeRoute[] = [] provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() return { @@ -131,7 +128,6 @@ describe('connection node half', () => { ctx.provide('attachments', { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 }, } as AttachmentStore) - ctx.provide('apiProxy', {} as ApiProxy) await expect(apply(ctx, { maxRequestBodyBytes: 1024 })) .rejects.toThrow(/must be at least .* aggregate image limit/) expect(routes).toHaveLength(0) @@ -143,7 +139,6 @@ describe('connection node half', () => { const ctx = new Context() provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) @@ -243,7 +238,7 @@ describe('connection node half', () => { await dispose() }) - it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { + it('provides a disposable dedicated RPC channel', async () => { const ctx = new Context() const routes: WebRoute[] = [] provideBrowserCredentials(ctx) @@ -292,12 +287,11 @@ describe('connection node half', () => { expect(routes).toHaveLength(0) }) - it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { + it('dispatches claimed /api endpoints and withdraws the claim', async () => { const ctx = new Context() const routes: WebRoute[] = [] provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) - ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() const connection = ctx.get('connection') as HostConnectionHandle diff --git a/packages/client/connection/tests/rpc-schema.host.spec.ts b/packages/client/connection/tests/rpc-schema.host.spec.ts new file mode 100644 index 0000000000..1d34e58ac3 --- /dev/null +++ b/packages/client/connection/tests/rpc-schema.host.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { RpcId, transportError } from '../src/rpc.ts' +import { + clientRequestSchema, + rpcErrorSchema, + rpcIdSchema, + rpcMessageSchema, + rpcResultSchema, + serverResponseSchema, +} from '../src/rpc-schema.ts' +import { z } from 'zod' + +describe('Connection RPC schema', () => { + it('brands any validated string correlation id', () => { + expect(RpcId('abc')).toBe('abc') + expect(rpcIdSchema.parse('')).toBe('') + expect(() => rpcIdSchema.parse(42)).toThrow() + }) + + it('folds transport exceptions into an internal failure', () => { + expect(transportError(new Error('wire down'))).toEqual({ + ok: false, + error: { code: 'internal', message: 'wire down', details: {} }, + }) + expect(transportError('raw')).toMatchObject({ + ok: false, + error: { code: 'internal', message: 'raw' }, + }) + }) + + it('validates generic failures and both result branches', () => { + expect(rpcErrorSchema.parse({ code: 'domain-failure', message: 'failed', details: { id: 'x' } })) + .toEqual({ code: 'domain-failure', message: 'failed', details: { id: 'x' } }) + expect(() => rpcErrorSchema.parse({ code: 1, message: 'failed', details: {} })).toThrow() + expect(() => rpcErrorSchema.parse({ code: 'failed', message: 'failed', details: [] })).toThrow() + + const schema = rpcResultSchema(z.object({ n: z.number() })) + expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } }) + expect(schema.parse({ ok: false, error: { code: 'failed', message: 'x', details: {} } })) + .toMatchObject({ ok: false }) + expect(() => schema.parse({ ok: true, error: {} })).toThrow() + }) + + it('validates both envelope directions and valueless success', () => { + const request = { type: 'client-request', rpcId: 'r1', method: 'settings/describe', payload: { args: {} } } + const response = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } } + expect(clientRequestSchema.parse(request).method).toBe('settings/describe') + expect(serverResponseSchema.parse(response).rpcId).toBe('r1') + for (const message of [request, response]) expect(rpcMessageSchema.parse(message)).toBeTruthy() + expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() + expect(() => clientRequestSchema.parse({ type: 'client-request', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1' })).toThrow() + expect(() => serverResponseSchema.parse({ type: 'server-response', rpcId: 'r1', result: {} })).toThrow() + expect(serverResponseSchema.parse({ + type: 'server-response', rpcId: 'r1', result: { ok: true }, + }).rpcId).toBe('r1') + }) +}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 6d5533c9b7..5732fd31f1 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -13,7 +13,6 @@ "src/client/index.ts", "src/client/random-uuid.ts", "src/client/rpc.ts", - "src/client/web-api-client.ts", "src/loopback-hostname.ts", "src/rpc.ts" ], @@ -39,9 +38,6 @@ { "path": "../../settings/settings" }, - { - "path": "../../host/apiproxy" - }, { "path": "../../host/directory-picker" }, diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index ad9bacc704..baeeccf4a4 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -14,6 +14,7 @@ "src/invariant.ts", "src/loopback-hostname.ts", "src/rpc-host.ts", + "src/rpc-schema.ts", "src/rpc.ts" ], "references": [ @@ -24,13 +25,16 @@ "path": "../../credentials/credentials" }, { - "path": "../../host/apiproxy" + "path": "../../core/session" }, { "path": "../../host/webserver" }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/brand" } ] } diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index dfebafe874..984a6a6828 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -58,7 +58,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:host-apiproxy|file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|util-crypto|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/credentials/authorization/src/types.ts b/packages/credentials/authorization/src/types.ts index ea75db8d7c..32e55615c2 100644 --- a/packages/credentials/authorization/src/types.ts +++ b/packages/credentials/authorization/src/types.ts @@ -1,6 +1,6 @@ /** * Wire-safe authorization types, free of cordis/service imports so browser type - * chains (apiproxy api → client) can consume them without loading this + * chains can consume them without loading this * package's Context augmentation. * @module @deepseek-ai/dsh-authorization/types */ diff --git a/packages/experimental/webworker-packer/src/rules.ts b/packages/experimental/webworker-packer/src/rules.ts index d05330dbb2..7c579dda47 100644 --- a/packages/experimental/webworker-packer/src/rules.ts +++ b/packages/experimental/webworker-packer/src/rules.ts @@ -65,7 +65,6 @@ export const PAGE_ASSETS: readonly string[] = [ export const IMAGE_ENTRY_SEEDS: readonly string[] = [ '@deepseek-ai/dsh-app-boot', '@deepseek-ai/dsh-cmdline', - '@deepseek-ai/dsh-host-apiproxy', '@deepseek-ai/cordis', '@deepseek-ai/cordis-plugin-include', 'js-yaml', diff --git a/packages/experimental/webworker-packer/tsconfig.json b/packages/experimental/webworker-packer/tsconfig.json index 7039bfa53b..f31531c778 100644 --- a/packages/experimental/webworker-packer/tsconfig.json +++ b/packages/experimental/webworker-packer/tsconfig.json @@ -11,6 +11,9 @@ "src" ], "references": [ + { + "path": "../../../vendor/include" + }, { "path": "../webworker-runtime" }, diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 68643425f8..bfcff40db5 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -41,8 +41,8 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" }, @@ -51,8 +51,8 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", diff --git a/packages/experimental/webworker-runtime/src/client/api-client.ts b/packages/experimental/webworker-runtime/src/client/api-client.ts deleted file mode 100644 index 17bbc8f270..0000000000 --- a/packages/experimental/webworker-runtime/src/client/api-client.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Page-side unary API carrier over the postMessage tunnel. Gateway Remote - * streams use the tunnel's dedicated logical-stream frames instead of this - * fetch-shaped API path. - */ -import { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' -import type { WorkerTunnel } from './client.ts' - -/** API client whose requests travel the worker tunnel instead of the network. */ -export class WorkerApiClient extends AbstractApiClient { - private readonly tunnel: WorkerTunnel - - /** - * Bind the carrier to a tunnel. - * @param tunnel - page half of the worker tunnel. - */ - constructor(tunnel: WorkerTunnel) { - super() - this.tunnel = tunnel - } - - /** - * Send one request through the tunnel. - * @param input - request URL. - * @param init - fetch init; the tunnel honours method, headers, body, and signal. - * @returns the reconstructed response. - */ - protected doFetch(input: URL, init?: RequestInit): Promise { - return this.tunnel.fetch(input, init) - } -} diff --git a/packages/experimental/webworker-runtime/src/client/index.ts b/packages/experimental/webworker-runtime/src/client/index.ts index 8494e1c06a..7f4af6e1a9 100644 --- a/packages/experimental/webworker-runtime/src/client/index.ts +++ b/packages/experimental/webworker-runtime/src/client/index.ts @@ -10,12 +10,10 @@ */ import { IMAGE_FILE_NAME } from '../image-layout.ts' import { PREVIEW_FIXTURE_MANIFEST_FILE } from '../fixture-manifest.ts' -import { WorkerApiClient } from './api-client.ts' import { WorkerTunnel, type TunnelFetch } from './client.ts' import { applyIndexInjections } from './apply-injections.ts' import { choosePreviewSource } from './source-chooser.ts' -export { WorkerApiClient } from './api-client.ts' export { WorkerTunnel, type TunnelFetch } from './client.ts' export { applyIndexInjections } from './apply-injections.ts' export { IMAGE_FILE_NAME } from '../image-layout.ts' @@ -27,7 +25,6 @@ export { /** Transport global the connection plugin reads instead of building an HTTP carrier. */ interface ClientTransportGlobal { __DSH_TRANSPORT__?: { - createApiClient: () => WorkerApiClient fetch: TunnelFetch openStream: (endpoint: string, payload: unknown, signal: AbortSignal) => AsyncIterable loadBundle: (url: string) => Promise @@ -147,7 +144,6 @@ export async function connectWorkerHost(worker: Worker, options?: WorkerHostConn ) const payload = await tunnel.bootPayload() ;(globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ = { - createApiClient: () => new WorkerApiClient(tunnel), fetch: (input, init) => tunnel.fetch(input, init), openStream: (endpoint, payload, signal) => tunnel.open(endpoint, payload, signal), loadBundle: (url: string) => tunnel.loadBundle(url), diff --git a/packages/experimental/webworker-runtime/src/node/builtins.ts b/packages/experimental/webworker-runtime/src/node/builtins.ts index 5725ae0642..9f7947dc06 100644 --- a/packages/experimental/webworker-runtime/src/node/builtins.ts +++ b/packages/experimental/webworker-runtime/src/node/builtins.ts @@ -2,7 +2,7 @@ * The Node-compatibility table, in one place. Two consumers share it, and they * must resolve to the same module instances: * - the worker vite build aliases these specifiers for code bundled statically - * into the worker (vendored loader, apiproxy, …); + * into the worker (vendored loader, Connection, …); * - the worker module loader answers `require('node:fs')` from VFS-loaded * modules out of this table, before bare-name resolution. * Anything absent here fails loudly at resolution instead of resolving to an diff --git a/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts b/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts index 380cae53d4..caa90d3b5f 100644 --- a/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts +++ b/packages/experimental/webworker-runtime/src/node/external_packages/ws.ts @@ -1,6 +1,6 @@ /** * `ws` stub. `WebSocketDownlinks` constructs a `WebSocketServer` in a field - * initializer as soon as apiProxy is present, so the class must be constructible; + * initializer as soon as Connection is present, so the class must be constructible; * no method is ever reached because the fake HTTP server never emits `upgrade` * (the tunnel carries downstream events over the SSE branch instead). */ diff --git a/packages/experimental/webworker-runtime/src/worker-host.ts b/packages/experimental/webworker-runtime/src/worker-host.ts index 27ffb27214..70b70ac9ed 100644 --- a/packages/experimental/webworker-runtime/src/worker-host.ts +++ b/packages/experimental/webworker-runtime/src/worker-host.ts @@ -23,6 +23,7 @@ */ import { setActiveModuleLoader, WorkerModuleLoader, type StaticModuleFactory } from './module-system/module-loader.ts' import type { TypertGateway } from '@deepseek-ai/dsh-api-gateway' +import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' import type { AlsCausality } from './polyfill/async-context/als-runtime.ts' import { dirname, join } from './module-system/posix-path.ts' import { installProcessGlobal } from './node/globals/process.ts' @@ -242,19 +243,15 @@ export function createWorkerHost(options: WorkerHostOptions): WorkerHost { }) context = ctx - const apiProxy = ctx.get('apiProxy') - if (apiProxy === undefined) throw new Error('webworker host: the tree activated without an apiProxy service') + const connection = ctx.get('connection') as HostConnectionHandle | undefined + if (connection === undefined) throw new Error('webworker host: the tree activated without a Connection service') const typertGateway = ctx.get('typertGateway') as TypertGateway | undefined if (typertGateway === undefined) { throw new Error('webworker host: the tree activated without a typertGateway service') } - const { toFetchHandler } = require('@deepseek-ai/dsh-host-apiproxy') as { - toFetchHandler: (api: unknown) => { fetch(request: Request): Promise } - } - const shared = ctx.get('connection') !== undefined - const handler = directFetchHandler(ctx, toFetchHandler(apiProxy)) + const handler = connection.createSharedFetchHandler('/api') const usage = loader.usage() - console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=${shared ? 'connection.createSharedFetchHandler (interceptors kept)' : 'api surface only'}, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) + console.info(`webworker host: tree active (modules=${String(usage.modules)}, data overlays=${String(overlays.length)}, preset root overlay=${presetOverlay ? 'applied' : 'already in roster'}, direct lane=connection.createSharedFetchHandler, als causality=${options.alsCausality === undefined ? 'inert' : 'snapshot/restore'}, image lowering=${LOWERING_VERSION})`) tunnel.serve({ directFetch: (request: Request) => handler.fetch(request), @@ -349,33 +346,6 @@ function requireLoweredImage(vfs: MemoryVfs, path: string): void { } } -/** - * Build the tunnel's direct API entry. - * - * The core API surface alone is not the whole `/api` channel: Typert RPC - * endpoints (`/api//`) are served by an interceptor the gateway - * registers on the Connection service, and answer 404 from the core routes. The - * Connection service composes both halves in `createSharedFetchHandler`, whose - * fallback — not the composition — carries network authentication and trust, so - * composing it here keeps every interceptor while leaving out the fences the - * worker-local direct lane exists to bypass. - * @param ctx - Booted host context. - * @param core - Fetch handler over the API surface. - * @returns Handler covering interceptors and the core surface. - */ -function directFetchHandler( - ctx: HostContext, - core: { fetch(request: Request): Promise }, -): { fetch(request: Request): Promise } { - const connection = ctx.get('connection') as { - createSharedFetchHandler( - channel: '/api', - fallback: { fetch(request: Request): Promise }, - ): { fetch(request: Request): Promise } - } | undefined - return connection?.createSharedFetchHandler('/api', core) ?? core -} - /** * The shipped preset root, as the application layer that owns the composition * supplies it. diff --git a/packages/experimental/webworker-runtime/tsconfig.json b/packages/experimental/webworker-runtime/tsconfig.json index 1c7b93807b..ab3ef04aee 100644 --- a/packages/experimental/webworker-runtime/tsconfig.json +++ b/packages/experimental/webworker-runtime/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../client/modules" }, { - "path": "../../host/apiproxy" + "path": "../../client/connection/tsconfig.host.json" }, { "path": "../../host/webserver" diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index a4f182a645..858d359027 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -485,13 +485,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ConnectionConfig', declaration: 'export interface ConnectionConfig {\n backoffBaseMs?: number;\n backoffFactor?: number;\n backoffMaxMs?: number;\n generationReadyTimeoutMs?: number;\n}', }, + { + name: 'ConnectionGeneration', + declaration: 'export interface ConnectionGeneration {\n readonly id: number;\n readonly host: ConnectionHostInfo;\n}', + }, { name: 'ConnectionGenerationSource', - declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: () => void) => Promise;', + declaration: 'export type ConnectionGenerationSource = (signal: AbortSignal, ready: (host: ConnectionHostInfo) => void) => Promise;', + }, + { + name: 'ConnectionGenerationState', + declaration: 'export interface ConnectionGenerationState {\n getSnapshot(): ConnectionGeneration | undefined;\n subscribe(listener: () => void): () => void;\n}', }, { name: 'ConnectionHandle', - declaration: 'export interface ConnectionHandle {\n readonly api: IApiClient;\n readonly isLoopback: boolean;\n readonly hostDescription: HostDescriptionSource;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', + declaration: 'export interface ConnectionHandle {\n readonly isLoopback: boolean;\n readonly generation: ConnectionGenerationState;\n readonly rpc: ClientConnectionRpc;\n registerGenerationSource(source: ConnectionGenerationSource): () => void;\n start(sinks: ConnectionSinks, config?: ConnectionConfig): {\n stop(): void;\n };\n}', + }, + { + name: 'ConnectionHostInfo', + declaration: 'export interface ConnectionHostInfo {\n readonly home: string;\n}', }, { name: 'ConnectionRpcFailure', @@ -503,7 +515,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ConnectionSinks', - declaration: 'export interface ConnectionSinks {\n onConnected?: (description: HostDescription) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', + declaration: 'export interface ConnectionSinks {\n onConnected?: (host: ConnectionHostInfo) => void;\n onStateChange?: (state: ConnectionState) => void;\n}', }, { name: 'ConnectionState', @@ -525,14 +537,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'HooksSources', declaration: 'export type HooksSources = Record>;', }, - { - name: 'HostDescription', - declaration: 'export type HostDescription = import(\'@deepseek-ai/dsh-host-apiproxy/api\').ResponseValue<\'host.describe\'>;', - }, - { - name: 'HostDescriptionSource', - declaration: 'export interface HostDescriptionSource {\n getSnapshot(): HostDescription | undefined;\n subscribe(listener: () => void): () => void;\n}', - }, { name: 'HostObservable', declaration: 'export type HostObservable = ObservableSnapshot;', @@ -651,7 +655,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'RemoteStream', - declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', + declaration: 'export class RemoteStream implements AsyncIterable> {\n constructor(private readonly connection: Pick, private readonly options: RemoteStreamOptions);\n get signal(): AbortSignal;\n restart(): void;\n dispose(): Promise;\n [Symbol.asyncIterator](): AsyncIterator>;\n}', }, { name: 'RemoteStreamCarrierError', diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 4891cd5537..cb8f1a7ebc 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -2133,9 +2133,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ownerProps: [ '/** Standard owner currency supplied to every atomic Tool view. */\nexport interface ToolCallOwnerProps {\n /** Tool call identity, stable across running and settled forms. */\n callId: string\n /** Wire Tool name and keyed dispatch value. */\n toolName: string\n /** Frozen running call or settled result node. */\n block: ToolCallBlock\n /** Session workspace root for relative summaries. */\n cwd?: string | undefined\n /** Host account home; POSIX home-rooted summaries display as `~`. */\n home?: string | undefined\n /** Open a Tool argument path through the Host. */\n openFile: (path: string) => void\n /** Inspect this call in the trajectory view when available. */\n inspect?: (() => void) | undefined\n}', ], - ownerPropsReferences: [ - 'Wire', - ], + ownerPropsReferences: [], standardProps: [ 'useWorkspaces: SnapshotSelectorHook', 'useSessions: UseSessions', diff --git a/packages/interaction/user-approval/src/types.ts b/packages/interaction/user-approval/src/types.ts index 862d1739f0..c4b18260e9 100644 --- a/packages/interaction/user-approval/src/types.ts +++ b/packages/interaction/user-approval/src/types.ts @@ -1,6 +1,6 @@ /** * Wire-safe approval identifiers and outcome vocabulary, free of - * cordis/service imports so browser type chains (apiproxy api → client) can + * cordis/service imports so browser type chains can * consume them without loading this package's Context augmentation. * @module @deepseek-ai/dsh-user-approval/types */ diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 05b2c4044b..dd25b0dfcf 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -92,7 +92,6 @@ describe('client bundle purity gate', () => { }) it('lets inline-safe wire layers inline', () => { - expect(resolveId('@deepseek-ai/dsh-host-apiproxy/api')).toBeNull() expect(resolveId('@deepseek-ai/dsh-session/surface')).toBeNull() expect(resolveId('@deepseek-ai/dsh-brand')).toBeNull() expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull() @@ -218,10 +217,10 @@ describe('client bundle debug artifacts', () => { if (transform === undefined) throw new Error('client sourcemap path transform missing') const sourceMapPath = clientSourceMapPath('client/connection') - const workspaceSource = transform('../../../host/apiproxy/src/api/rpc.ts', sourceMapPath) - expect(workspaceSource).toBe('../../../packages/host/apiproxy/src/api/rpc.ts') + const workspaceSource = transform('../src/rpc.ts', sourceMapPath) + expect(workspaceSource).toBe('../../../packages/client/connection/src/rpc.ts') const resolved = new URL(workspaceSource, 'https://dsh.test/plugins/@deepseek-ai/dsh-client-connection/client.js.map') - expect(resolved.pathname).toBe('/packages/host/apiproxy/src/api/rpc.ts') + expect(resolved.pathname).toBe('/packages/client/connection/src/rpc.ts') const dependencySource = '../../../../node_modules/.pnpm/zod@4.4.3/node_modules/zod/index.js' expect(transform(dependencySource, sourceMapPath)).toBe(dependencySource) From 4f00a8b82af9145d9ee19d5201972ef92fb311da Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:51:58 +0800 Subject: [PATCH 113/130] refactor(api): remove ApiProxy package --- apps/cli/package.json | 1 - apps/cli/tsconfig.json | 3 - packages/bundle/web-app/cordis.patch.yml | 5 - packages/bundle/web-app/package.json | 1 - .../extensions/tool-cordis/src/api-catalog.ts | 60 +- packages/host/apiproxy/README.i18n.yaml | 6 - packages/host/apiproxy/README.md | 135 ---- packages/host/apiproxy/README.zh.md | 135 ---- packages/host/apiproxy/package.json | 74 -- packages/host/apiproxy/src/api-proxy.ts | 137 ---- .../host/apiproxy/src/api/downloads.schema.ts | 25 - packages/host/apiproxy/src/api/downloads.ts | 24 - packages/host/apiproxy/src/api/host.schema.ts | 21 - packages/host/apiproxy/src/api/host.ts | 30 - packages/host/apiproxy/src/api/ids.schema.ts | 7 - packages/host/apiproxy/src/api/index.ts | 44 -- packages/host/apiproxy/src/api/rpc-map.ts | 23 - packages/host/apiproxy/src/api/rpc.schema.ts | 83 -- packages/host/apiproxy/src/api/rpc.ts | 104 --- packages/host/apiproxy/src/fetch/client.ts | 204 ----- packages/host/apiproxy/src/fetch/handler.ts | 158 ---- packages/host/apiproxy/src/index.ts | 91 --- packages/host/apiproxy/src/invariant.ts | 32 - packages/host/apiproxy/src/session-export.ts | 457 ----------- .../apiproxy/tests/api-proxy-config.spec.ts | 236 ------ .../apiproxy/tests/api-proxy-host.spec.ts | 51 -- .../apiproxy/tests/client-handler.spec.ts | 228 ------ .../host/apiproxy/tests/fetch-carrier.spec.ts | 207 ----- .../host/apiproxy/tests/rpc-schemas.spec.ts | 96 --- .../apiproxy/tests/session-export.spec.ts | 708 ------------------ packages/host/apiproxy/tsconfig.json | 54 -- .../generator/tests/cordis-catalog.spec.ts | 3 - pnpm-lock.yaml | 88 +-- scripts/check-workspace-constraints.ts | 2 +- scripts/doc-typecheck-paths.ts | 2 +- scripts/gen-cordis-catalog.ts | 6 +- scripts/gen-doc-graphs.ts | 26 +- .../verify-package-readme-model-experience.ts | 1 - tsconfig.base.json | 9 +- tsconfig.client.json | 2 +- tsconfig.host.json | 1 - vitest.config.ts | 3 - 42 files changed, 54 insertions(+), 3529 deletions(-) delete mode 100644 packages/host/apiproxy/README.i18n.yaml delete mode 100644 packages/host/apiproxy/README.md delete mode 100644 packages/host/apiproxy/README.zh.md delete mode 100644 packages/host/apiproxy/package.json delete mode 100644 packages/host/apiproxy/src/api-proxy.ts delete mode 100644 packages/host/apiproxy/src/api/downloads.schema.ts delete mode 100644 packages/host/apiproxy/src/api/downloads.ts delete mode 100644 packages/host/apiproxy/src/api/host.schema.ts delete mode 100644 packages/host/apiproxy/src/api/host.ts delete mode 100644 packages/host/apiproxy/src/api/ids.schema.ts delete mode 100644 packages/host/apiproxy/src/api/index.ts delete mode 100644 packages/host/apiproxy/src/api/rpc-map.ts delete mode 100644 packages/host/apiproxy/src/api/rpc.schema.ts delete mode 100644 packages/host/apiproxy/src/api/rpc.ts delete mode 100644 packages/host/apiproxy/src/fetch/client.ts delete mode 100644 packages/host/apiproxy/src/fetch/handler.ts delete mode 100644 packages/host/apiproxy/src/index.ts delete mode 100644 packages/host/apiproxy/src/invariant.ts delete mode 100644 packages/host/apiproxy/src/session-export.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-config.spec.ts delete mode 100644 packages/host/apiproxy/tests/api-proxy-host.spec.ts delete mode 100644 packages/host/apiproxy/tests/client-handler.spec.ts delete mode 100644 packages/host/apiproxy/tests/fetch-carrier.spec.ts delete mode 100644 packages/host/apiproxy/tests/rpc-schemas.spec.ts delete mode 100644 packages/host/apiproxy/tests/session-export.spec.ts delete mode 100644 packages/host/apiproxy/tsconfig.json diff --git a/apps/cli/package.json b/apps/cli/package.json index 5076da6592..a07102d212 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -110,7 +110,6 @@ "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 135838240c..7b0a769721 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -32,9 +32,6 @@ { "path": "../../packages/bundle/web-app" }, - { - "path": "../../packages/host/apiproxy" - }, { "path": "../../packages/host/webserver" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index d69e66753a..f63af2885a 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -95,11 +95,6 @@ - id: workspace-controller name: '@deepseek-ai/dsh-api-workspace-controller' - # The API gateway: the transport-agnostic dispatch face every client shape - # shares. The base layer's agent-default-model service owns the default model. - - id: api-gateway - name: '@deepseek-ai/dsh-host-apiproxy' - - id: cordis-host-runner name: '@deepseek-ai/dsh-cordis-host-runner' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 0f216073de..85a61a1d5b 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -94,7 +94,6 @@ "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-web-frontend": "workspace:^", "@deepseek-ai/dsh-host-frontend-static": "workspace:^", - "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 37d29ce704..4012db1069 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -429,18 +429,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'apiProxy', - summary: 'Root interface of the unified API.', - description: 'Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row.', - methods: [ - { - signature: 'downloads: DownloadsApi', - description: 'Host-only download surfaces (GET, no wire envelope); absent from IApiClient.', - parameters: [], - }, - ], - }, { key: 'approval', summary: 'Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session.', @@ -1382,6 +1370,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], returns: 'provider-grouped models, the deployment default, and isolated provider failures.', }, + { + signature: '@Remote canOpenWorkspacePath(): boolean', + description: 'Report whether this deployment can hand a Session workspace path to a native desktop.', + parameters: [], + returns: 'true when the matching open operation is available.', + }, { signature: '@Remote(\'openWorkspacePath\') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise', description: 'Open one path prepared by a Session-aware caller on the Host desktop.', @@ -1966,6 +1960,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ returns: 'provider writability, local-document presence, and one view per namespace.', throws: ['TypertRemoteFailure when no settings provider is mounted.'], }, + { + signature: '@Remote canOpenAgentPresetDirectory(): boolean', + description: 'Report whether this deployment can open an authored Agent preset directory natively.', + parameters: [], + returns: 'true when the matching open operation is available.', + }, { signature: '@Remote update( ns: string, patch: Record, expectedRevision: number | undefined, ): Promise', description: 'Merge a patch into one namespace\'s stored user section.', @@ -2610,9 +2610,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'registerRemoteEvents(source: TypertRemoteEventSource): () => Promise', + signature: 'registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise', description: 'Register the sole application-selected forwarded-event source.', - parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }], + parameters: [{ name: 'source', description: 'stream factory installed by the Remote assembly.' }, { name: 'host', description: 'stable Host facts included in each Client generation\'s opening frame.' }], returns: 'disposer removing this source and cancelling its active streams.', }, { @@ -3934,10 +3934,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DomainTableSpec', declaration: 'export interface DomainTableSpec {\n readonly valueSchema: ZodType;\n readonly __key?: K;\n}', }, - { - name: 'DownloadsApi', - declaration: 'export interface DownloadsApi {\n sessionLog(request: {\n sessionId: SessionId;\n includeDescendants?: boolean;\n }, signal: AbortSignal): Promise;\n}', - }, { name: 'DshEnvironment', declaration: 'export type DshEnvironment = Readonly>;', @@ -4606,6 +4602,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'RedactedSecret', declaration: 'export interface RedactedSecret {\n path: string[];\n set: boolean;\n}', }, + { + name: 'RemoteEventHostInfo', + declaration: 'export interface RemoteEventHostInfo {\n readonly home: string;\n}', + }, { name: 'ReplayEnvelope', declaration: 'export interface ReplayEnvelope {\n response: unknown;\n blocks?: readonly unknown[];\n}', @@ -4662,26 +4662,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ResumeAgentOptions', declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: AgentSetup;\n}', }, - { - name: 'RpcError', - declaration: 'export type RpcError = {\n [C in RpcErrorCode]: {\n code: C;\n message: string;\n details: RpcErrorDetailsMap[C];\n };\n}[RpcErrorCode];', - }, - { - name: 'RpcErrorCode', - declaration: 'export type RpcErrorCode = keyof RpcErrorDetailsMap;', - }, - { - name: 'RpcErrorDetailsMap', - declaration: 'export interface RpcErrorDetailsMap {\n \'bad-request\': {\n issues: ZodIssue[];\n };\n \'cancelled\': {};\n \'session-not-found\': {\n sessionId: SessionId;\n };\n \'invalid-time-zone\': {\n value: string;\n };\n \'agent-preset-read-only\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-preset-locked\': {\n sessionId: SessionId;\n agentPreset: string;\n };\n \'agent-preset-not-found\': {\n agentPreset: string;\n available: readonly string[];\n };\n \'agent-preset-invalid\': {\n agentPreset: string;\n reason: string;\n };\n \'agent-busy\': {\n reason: string;\n };\n \'internal\': {};\n}', - }, - { - name: 'RpcId', - declaration: 'export type RpcId = Branded<\'rpc-id\'>;', - }, - { - name: 'RpcResult', - declaration: 'export type RpcResult = {\n ok: true;\n value: T;\n} | {\n ok: false;\n error: RpcError;\n};', - }, { name: 'RunnerFailureRule', declaration: 'export interface RunnerFailureRule {\n allowedExitCodes?: readonly number[];\n fatalSignatures: readonly string[];\n informationalLines?: readonly string[];\n}', @@ -4758,10 +4738,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SendTeamMessageResult', declaration: 'export interface SendTeamMessageResult {\n readonly messageId: TeamMessageId;\n readonly status: \'accepted\' | \'queued\';\n}', }, - { - name: 'ServerResponse', - declaration: 'export interface ServerResponse {\n type: \'server-response\';\n rpcId: RpcId;\n result: RpcResult;\n}', - }, { name: 'Session', declaration: 'export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n}', diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml deleted file mode 100644 index a339fcb16a..0000000000 --- a/packages/host/apiproxy/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 131ea9c510733740664ca8b46510650110bca234 -README.zh.md: 4578e89acc73f1a77648dce087da1bca6b364f16 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md deleted file mode 100644 index 131ea9c510..0000000000 --- a/packages/host/apiproxy/README.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -description: "Legacy HTTP transport for Host bootstrap metadata and streamed Session-log ZIP downloads while generated Typert Remotes own business operations." -kind: "package-reference" ---- - -# @deepseek-ai/dsh-host-apiproxy - -English | [中文](README.zh.md) - -## Summary - -`dsh-host-apiproxy` carries the two Host operations that do not yet belong to a generated business Remote: the `host.describe` bootstrap snapshot and streamed Session-log ZIP downloads. Its browser-safe envelope and fetch adapters serve HTTP and in-process clients, while API Gateway carries all ordinary business operations. The shipped Web composition assembles both transports in [`dsh-web-app`](../../bundle/web-app/README.md). - -## Table of Contents - -- [Use this package](#use-this-package) -- [Understand the implementation](#understand-the-implementation) -- [Further Exploration](#further-exploration) -- [Model Experience](#model-experience) -- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) -- [Dev Note](#dev-note) - ------ - - -## Use this package - -Compose this package when a GUI host needs bootstrap metadata and Session-log export: load `ApiProxyService`, wrap `ctx.apiProxy` in a carrier, and use generated Remotes for all other business calls. - -### Choosing a carrier - -`toFetchHandler(api)` turns the gateway into a pure WHATWG fetch function for an HTTP server (the shipped Web composition exposes it behind `/api/…` routes), while `InProcessApiClient` runs the same serialization and validation path in-process — the isomorphic point for callers and tests that need the full wire path without a network. - -```text -const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) -const response = await client.host.describe({}) -``` - -The HTTP carrier refuses non-JSON POST bodies with 415 before dispatch, so cross-site simple requests can never run a side-effectful method blind. The browser carrier applies the same Host/Origin checks and signed-cookie authentication to every Host API method ([`dsh-client-connection`](../../client/connection/README.md)); individual Client features may still withhold native or persistent operations on non-loopback pages. - -### What the gateway exposes - -The unary map contains only `host.describe`; the direct download route is `GET` or `HEAD /api/session.export`. Session, workspace, settings, credentials, LLM, skill, file-reference, command, and interaction operations are generated Remotes owned by their business packages and assembled by [`dsh-api-remotes`](../../api/remotes/README.md). - -### Exporting sessions - -`GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP of the session's stored artifact text verbatim, every subagent descendant under `subagents//`, and each referenced image under `media/.`. `HEAD` runs the same root preparation without a body, so browsers detect pre-stream failures before handing the GET to the download manager. The response is chunked as it is produced, and `sessionExportCompressionLevel` (0–9, default 6) trades CPU and latency against archive size. Missing persistence, session-query, or attachment services answer 500, a backend without per-session raw artifacts 501, and a missing root session 404. - -### Configuration - -| Field | Default | Meaning | -|---|---|---| -| `nativeOpen` | platform-detected | Whether the deployment can hand paths to a native desktop opener | -| `sessionExportCompressionLevel` | `6` | DEFLATE level for every session-log ZIP entry, 0–9 | - -The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) is the exhaustive source for every accepted field and its JSDoc. - ------ - - -## Understand the implementation - -

    -Implementation internals — click to expand - -### Design concept - -The package is built on one separation: the API contract is channel-independent, and physical transports are carriers around it. Wire messages form a two-member discriminated union — `ClientRequest` (the POST `/api/` body) and `ServerResponse` (that POST's response body) — decoupled from the physical channel. Responses always echo the matching request's `rpcId` and never mint a new one. Business errors ride the `RpcResult` error branch with a closed `RpcErrorDetailsMap`; HTTP status expresses only the carrier. The layering and protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). - -### Source map - -| File | Role | -|---|---| -| [`src/api/`](src/api/) | Contract layer: domain interfaces, payload types, zod schemas, `RpcMethodMap` — zero Node dependencies | -| [`src/fetch/handler.ts`](src/fetch/handler.ts) | Host carrier: `toFetchHandler`, envelope parsing, unary dispatch, session export | -| [`src/fetch/client.ts`](src/fetch/client.ts) | Client carrier: `AbstractApiClient` plus platform subclasses, `InProcessApiClient` | -| [`src/api-proxy.ts`](src/api-proxy.ts) | Gateway implementation: `createApiProxy` over the composed host context | -| [`src/session-export.ts`](src/session-export.ts) | Session-log ZIP export: raw artifact reads, media collection, fflate streaming | - -### The gateway service - -`ApiProxyService` provides `ctx.apiProxy`, reports process metadata through `host.describe`, and delegates Session archive production to the persistence, query, attachment, and live Session services. The Host cwd is the default project directory. Product `dsh --profile headless` is a direct core entry point and does not mount this package. - -### Request flow - -A `host.describe` request enters the fetch carrier, which parses the envelope and payload, dispatches the method, and returns a response echoing the request's `rpcId`. Session export bypasses that envelope because its streamed ZIP body and HTTP status are the result. - -### What the gateway owns - -The package owns its legacy envelope, Host bootstrap snapshot, and archive download. API Gateway owns generated Remote dispatch and streams; business packages own their methods and result types. - -
    - ------ - - -## Further Exploration - -Read these when the package-level contract is not enough. They move from the layering decision to the browser-side consumption architecture and the adjacent subsystems. - -- [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — the layering model and the channel-independent message protocol. -- [Web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how the browser consumes the API. -- [Browser HTTP carrier](../../client/connection/README.md) — Host/Origin checks, signed-cookie authentication, and the routes the shipped Web composition registers. -- [Web-server subsystem](../../../docs/subsystems/web-server.md) — the HTTP server the carrier rides on. -- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-apiproxy) — every accepted config field and its source declaration. - ------ - - -## Model Experience - -None, as the wire contract and fetch carriers move already-composed messages and register nothing model-facing. - -#### KV Cache effect - -None; this package neither assembles nor sends a provider request. - -## Known Limitations and Deferred Work - - - - -These limits define where the gateway is a poor fit; they are current package constraints, not a task backlog. - -- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists. - - -### Dev Note - -
    -Working context for maintainers — click to expand - -This Dev Note is working context for maintainers: open directions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above. A protocol version field waits for an independently released client; a multi-user carrier must replace provider search diagnostics with public-safe text; per-connection picker adaptivity (native for a local browser, browse for a remote one) remains an undecided direction for the host surface. - -
    diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md deleted file mode 100644 index 4578e89acc..0000000000 --- a/packages/host/apiproxy/README.zh.md +++ /dev/null @@ -1,135 +0,0 @@ ---- -description: "Host 启动元数据与 Session 日志 ZIP 流下载的旧版 HTTP 载体;普通业务操作由生成的 Typert Remote 持有。" -kind: "package-reference" ---- - -# @deepseek-ai/dsh-host-apiproxy - -[English](README.md) | 中文 - -## 概述 - -`dsh-host-apiproxy` 承载尚不属于生成业务 Remote 的两项 Host 操作:`host.describe` 启动快照与流式 Session 日志 ZIP 下载。它的浏览器安全 envelope 与 fetch adapter 服务 HTTP 和进程内客户端,其余普通业务操作由 API Gateway 承载。随发行版交付的 Web 组合在 [`dsh-web-app`](../../bundle/web-app/README.zh.md) 中组装两种传输。 - -## 目录 - -- [使用本包](#use-this-package) -- [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) -- [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) -- [开发备注](#dev-note) - ------ - - -## 使用本包 - -当 GUI Host 需要启动元数据与 Session 日志导出时组合本包:加载 `ApiProxyService`,把 `ctx.apiProxy` 包进一个载体,其他业务调用使用生成的 Remote。 - -### 选择载体 - -`toFetchHandler(api)` 把网关变成纯 WHATWG fetch 函数,供 HTTP 服务器使用(随发行版交付的 Web 组合把它暴露在 `/api/…` 路由之后);`InProcessApiClient` 则在进程内运行同一条序列化与校验路径——这是需要完整协议路径但不需要网络的调用方与测试的同构接点。 - -```text -const client = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) -const response = await client.host.describe({}) -``` - -HTTP 载体在分发前以 415 拒绝非 JSON 的 POST 请求体,因此跨站「简单请求」永远无法盲目执行有副作用的方法。浏览器载体对每个 Host API 方法实施相同的 Host/Origin 检查与签名 cookie 认证([`dsh-client-connection`](../../client/connection/README.zh.md));各 Client 功能仍可以在非 loopback 页面上拒绝原生操作或持久化操作。 - -### 网关暴露什么 - -一元映射只包含 `host.describe`;直接下载路由是 `GET` 或 `HEAD /api/session.export`。Session、workspace、settings、credentials、LLM、skill、file-reference、command 与 interaction 操作都是由各业务包持有、并由 [`dsh-api-remotes`](../../api/remotes/README.zh.md) 组装的生成 Remote。 - -### 导出会话 - -`GET /api/session.export?sessionId=…&includeDescendants=true` 流式输出一个 ZIP,其中每个会话的已存工件文本原样包含,每个子代理后代位于 `subagents//` 下,每张被引用的图片位于 `media/.` 下。`HEAD` 在无请求体的情况下运行同样的根准备,因此浏览器能在把 GET 交给下载管理器之前检测到流前失败。响应边生成边分块输出,`sessionExportCompressionLevel`(0–9,默认 6)在 CPU 与延迟之间权衡归档大小。缺少 persistence、session-query 或 attachment 服务时回答 500,后端没有按会话原始工件时回答 501,根会话缺失时回答 404。 - -### 配置 - -| 字段 | 默认值 | 含义 | -|---|---|---| -| `nativeOpen` | 平台探测 | 部署能否把路径交给原生桌面打开器 | -| `sessionExportCompressionLevel` | `6` | 每个会话日志 ZIP 条目的 DEFLATE 级别,0–9 | - -生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)是每个受支持字段及其 JSDoc 的穷尽式真源。 - ------ - - -## 理解实现 - -
    -实现细节——点击展开 - -### 设计理念 - -本包建立在一个分离之上:API 约定与通道无关,物理传输只是围绕它的载体。协议消息构成一个二元可辨识联合——`ClientRequest`(POST `/api/` 的请求体)与 `ServerResponse`(该 POST 的响应体)——与物理通道解耦。响应始终回显对应请求的 `rpcId`,绝不签发新值。业务错误由 `RpcResult` 的错误分支承载,其 `RpcErrorDetailsMap` 封闭错误码集合;HTTP 状态只表达载体层结果。分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md) 中。 - -### 源码地图 - -| 文件 | 职责 | -|---|---| -| [`src/api/`](src/api/) | 约定层:领域接口、payload 类型、zod schema、`RpcMethodMap`——零 Node 依赖 | -| [`src/fetch/handler.ts`](src/fetch/handler.ts) | 宿主载体:`toFetchHandler`、信封解析、一元分发、会话导出 | -| [`src/fetch/client.ts`](src/fetch/client.ts) | 客户端载体:`AbstractApiClient` 及平台子类、`InProcessApiClient` | -| [`src/api-proxy.ts`](src/api-proxy.ts) | 网关实现:基于所组合宿主上下文的 `createApiProxy` | -| [`src/session-export.ts`](src/session-export.ts) | 会话日志 ZIP 导出:原始工件读取、媒体收集、fflate 流式输出 | - -### 网关服务 - -`ApiProxyService` 提供 `ctx.apiProxy`,通过 `host.describe` 报告进程元数据,并把 Session 归档生成委派给 persistence、query、attachment 与 live Session 服务。Host cwd 是默认项目目录。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 - -### 请求流 - -`host.describe` 请求进入 fetch 载体,载体解析 envelope 与 payload、分发方法,并返回回显请求 `rpcId` 的响应。Session 导出不使用该 envelope,因为其流式 ZIP body 与 HTTP 状态就是结果。 - -### 网关拥有什么 - -本包持有旧版 envelope、Host 启动快照与归档下载。API Gateway 持有生成的 Remote 分发与流;业务包持有各自的方法和结果类型。 - -
    - ------ - - -## 进一步探索 - -当包级约定不够用时阅读以下内容。它们从分层决策进入浏览器侧消费架构与相邻子系统。 - -- [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——分层模型与通道无关的消息协议。 -- [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器如何消费该 API。 -- [浏览器 HTTP 载体](../../client/connection/README.zh.md)——Host/Origin 检查、签名 cookie 认证,以及随发行版交付的 Web 组合注册的路由。 -- [Web 服务器子系统](../../../docs/subsystems/web-server.zh.md)——载体所搭乘的 HTTP 服务器。 -- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-apiproxy)——每个受支持配置字段及其源声明。 - ------ - - -## 模型体验 - -无。该协议约定与 fetch 载体只搬运已组装好的消息,不注册任何面向模型的内容。 - -#### KV Cache 影响 - -无;该包既不组装也不发送提供方请求。 - -## 已知限制与延期工作 - - - - -这些限制说明网关在何处不合适;它们是当前包约束,不是任务积压。 - -- **没有协议版本字段**——客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。 - - -### 开发备注 - -
    -维护者的工作上下文——点击展开 - -本开发备注是维护者的工作上下文:开放方向。它明确不具权威性——已交付行为与限制见上文各节。协议版本字段等待独立发布的客户端;多用户载体必须把提供方搜索诊断替换为可安全公开的文本;按连接的自适应目录选择(本地浏览器用 native、远程浏览器用 browse)仍是宿主表面的一个未定方向。 - -
    diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json deleted file mode 100644 index b1eecd9a6b..0000000000 --- a/packages/host/apiproxy/package.json +++ /dev/null @@ -1,74 +0,0 @@ -{ - "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.1.1-rc.2", - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/host/apiproxy" - }, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json", - "./api": { - "types": "./lib/types/api/index.d.ts", - "default": "./lib/types/api/index.js" - }, - "./api/*": { - "types": "./lib/types/api/*.d.ts", - "default": "./lib/types/api/*.js" - }, - "./client": { - "types": "./lib/types/fetch/client.d.ts", - "default": "./lib/types/fetch/client.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.js", - "lib/types/**/*.d.ts" - ], - "license": "MIT", - "dependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-default-model": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", - "@deepseek-ai/schemastery": "workspace:^", - "fflate": "^0.8.2", - "zod": "^4.4.3" - }, - "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" - }, - "devDependencies": { - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" - } -} diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts deleted file mode 100644 index 6e502172f7..0000000000 --- a/packages/host/apiproxy/src/api-proxy.ts +++ /dev/null @@ -1,137 +0,0 @@ -/** - * Host-side ApiProxy implementation. Signature discipline: unary takes the - * narrow RpcRequest

    and echoes request.rpcId on the RpcResponse. - */ - -import { homedir } from 'node:os' -import type { Context } from '@deepseek-ai/cordis' -import type { ModelSelection } from '@deepseek-ai/dsh-agent' -import { canOpenNativePath } from '@deepseek-ai/dsh-native-command' -import type { ApiProxy } from './api/index.ts' -import { - DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, - flushLiveSessionLog, - sessionLogExportDeps, - sessionLogZipFilename, - streamSessionLogZip, - type SessionLogExportReady, - type SessionLogCompressionLevel, -} from './session-export.ts' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import type { RpcRequest, RpcResponse } from './api/rpc.ts' - -/** Wrap an ok result echoing the request's rpcId. */ -function ok(request: RpcRequest, value: T): RpcResponse { - return { rpcId: request.rpcId, result: { ok: true, value } } -} - -/** Deployment metadata and Host integrations consumed by the API implementation. */ -export interface ApiProxyDefaults { - /** Current deployment model selection reported by `host.describe`. */ - defaultModelSelection: () => ModelSelection - /** Project hint reported by `host.describe`; must match Session Controller's default cwd. */ - cwd: string - /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */ - sessionExportCompressionLevel?: SessionLogCompressionLevel - /** - * Whether `host.describe` reports that the Client may offer native path actions. - * Absent, platform detection decides ({@link canOpenNativePath}). - */ - canOpenPath?: () => boolean -} - -/** - * Implement ApiProxy over a composed host context. - * @param ctx - a context with the Host spine mounted. - * @param defaults - host routing and project-directory defaults. - * @returns the ApiProxy implementation. - */ -export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy { - const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel - ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL - /** Whether this deployment can hand a path to a native opener at all. */ - function canOpenPaths(): boolean { - if (defaults.canOpenPath !== undefined) return defaults.canOpenPath() - return canOpenNativePath() - } - - return { - host: { - describe(request) { - // TODO(apiproxy-version): read the version from apps/cli/package.json. - const selection = defaults.defaultModelSelection() - return Promise.resolve(ok(request, { - version: '0.0.1', - // This must match the default cwd supplied to Session Controller so - // the UI's project hint names where a cwd-less create request lands. - cwd: defaults.cwd, - // Read live for the same reason: this is what the NEXT session will - // start from, so a saved default has to be what it reports. - provider: selection.provider, - model: selection.model, - attachedSessions: ctx.agents.list().length, - home: homedir(), - canOpenPath: canOpenPaths(), - })) - }, - - }, - - downloads: { - async sessionLog(request, signal) { - // Clean error path first: missing services answer 500 and a missing - // root artifact 404 before any zip byte is produced. The root content - // read here is reused as the first zip entry, so nothing is read twice. - const deps = sessionLogExportDeps(ctx) - if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) { - return new Response( - 'session log export is unavailable: missing session-query, session-persistence, or attachments service', - { status: 500 }, - ) - } - if (!deps.sessionPersistence.supportsRawArtifacts) { - return new Response( - 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts', - { status: 501 }, - ) - } - const ready: SessionLogExportReady = { - sessionQuery: deps.sessionQuery, - sessionPersistence: deps.sessionPersistence, - attachments: deps.attachments, - sessions: deps.sessions, - } - let root: SessionRawArtifact | undefined - try { - await flushLiveSessionLog(deps, request.sessionId, signal) - root = await deps.sessionPersistence.readRaw(request.sessionId, signal) - signal.throwIfAborted() - } catch { - signal.throwIfAborted() - // Root preparation failure: answer 500 without echoing the error, - // which may carry absolute host paths into the browser error bar. - return new Response('session log export failed to prepare the stored artifact', { status: 500 }) - } - if (root === undefined) { - return new Response('session not found', { status: 404 }) - } - return new Response( - streamSessionLogZip( - ready, - root, - request.sessionId, - request.includeDescendants === true, - sessionExportCompressionLevel, - signal, - ), - { - headers: { - 'content-type': 'application/zip', - 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, - }, - }, - ) - }, - }, - } -} diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts deleted file mode 100644 index cd95cfbd45..0000000000 --- a/packages/host/apiproxy/src/api/downloads.schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * downloads domain zod schemas. The download surface has no wire - * envelope: the request arrives as query parameters (all strings), so its - * request schema parses the raw query-parameter object into the method's - * exact request shape. - */ - -import { z } from 'zod' -import type { DownloadsApi } from './downloads.ts' -import { sessionIdSchema } from './ids.schema.ts' - -/** - * session.export query params → the sessionLog request. `includeDescendants` - * accepts exactly `true`/`false`/absent; any other value is rejected (400) so - * a misspelled flag cannot silently under-export. - */ -export const sessionLogQuerySchema = z - .object({ - sessionId: sessionIdSchema, - includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(), - }) - .transform(query => ({ - sessionId: query.sessionId, - ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}), - })) satisfies z.ZodType[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts deleted file mode 100644 index fc8728e497..0000000000 --- a/packages/host/apiproxy/src/api/downloads.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * downloads domain contract: Host-only GET download surfaces with no wire - * envelope. Carrier routes answer these directly, and the browser - * `IApiClient` never exposes them. - */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** Host-only download surfaces (no wire envelope; absent from IApiClient). */ -export interface DownloadsApi { - /** - * Stream one session-log ZIP — the root artifact verbatim plus each subagent - * descendant's — as an attachment response. The carrier's GET route answers - * this directly; the browser never calls it. - * @param request - the root session id and whether to include descendants. - * @param signal - cancellation for the underlying reads. - * @returns the ZIP attachment response; missing services answer 500 and a - * missing root session 404 before any byte is produced. - */ - sessionLog( - request: { sessionId: SessionId; includeDescendants?: boolean }, - signal: AbortSignal, - ): Promise -} diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts deleted file mode 100644 index 5429b8cab0..0000000000 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * host domain zod schemas (names derived from map keys). - */ - -import { z } from 'zod' -import type { RequestPayload, ResponseValue } from './rpc-map.ts' -import type { Wire } from './rpc.schema.ts' - -/** host.describe request payload (empty object literal). */ -export const hostDescribeRequestSchema = z.object({}) satisfies z.ZodType>> - -/** host.describe response value. */ -export const hostDescribeValueSchema = z.object({ - version: z.string(), - cwd: z.string(), - provider: z.string().optional(), - model: z.string().optional(), - attachedSessions: z.number().int().nonnegative(), - home: z.string(), - canOpenPath: z.boolean(), -}) satisfies z.ZodType>> diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts deleted file mode 100644 index b256afbc00..0000000000 --- a/packages/host/apiproxy/src/api/host.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * host domain contract. No protocol version: client and host ship - * together; introduce protocolVersion only when an independently released client appears. - */ - -import type { RpcRequest, RpcResponse } from './rpc.ts' - -/** Host-level unary methods. */ -export interface HostApi { - /** - * One-shot host snapshot. Empty payload uses the literal `{}` (extend in place when fields arrive). - * version = the host app's (apps/cli) package.json version; cwd = the host process working - * directory (root for session persistence and tool execution); provider/model = the defaults - * applied when a new agent doesn't specify them explicitly, absent when the host configures - * no explicit default (the adapter falls back internally); - * attachedSessions = count of currently attached sessions (those with a live agent); - * home = the host account home directory (Web display abbreviation on POSIX); - * canOpenPath = whether this deployment can hand a path to a user-visible native desktop. - */ - describe(request: RpcRequest<{}>): Promise> - -} diff --git a/packages/host/apiproxy/src/api/ids.schema.ts b/packages/host/apiproxy/src/api/ids.schema.ts deleted file mode 100644 index a79d7dc848..0000000000 --- a/packages/host/apiproxy/src/api/ids.schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** Branded identity schemas shared by the remaining API Proxy domains. */ - -import type { SessionId } from '@deepseek-ai/dsh-session/types' -import { z } from 'zod' - -/** Non-empty Session identity after transport validation. */ -export const sessionIdSchema = z.string().min(1) as unknown as z.ZodType diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts deleted file mode 100644 index 7f6e5e8018..0000000000 --- a/packages/host/apiproxy/src/api/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * apiproxy contract-layer barrel. api/ has zero Node dependencies and is - * importable from the browser; the TypeScript interfaces are authoritative, - * while HTTP supplies the carrier. - */ - -import type { HostApi } from './host.ts' -import type { DownloadsApi } from './downloads.ts' - -/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */ -export interface ApiProxy { - host: HostApi - /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ - downloads: DownloadsApi -} - -// ---- Domain interfaces and payload entities ---- -export type { - ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, -} from '@deepseek-ai/dsh-api-session-controller/types' -export type { HostApi } from './host.ts' -export type { DownloadsApi } from './downloads.ts' - -// ---- Message layer: narrow forms (domain-signature view) ---- -export type { RpcRequest, RpcResponse } from './rpc.ts' - -// ---- Message layer: unary wire forms ---- -export type { - ClientRequest, - RpcMessage, - ServerResponse, -} from './rpc.ts' - -// ---- Errors and ids ---- -export { RpcId, transportError } from './rpc.ts' -export type { RpcError, RpcErrorCode, RpcErrorDetailsMap, RpcResult } from './rpc.ts' -export { - clientRequestSchema, - serverResponseSchema, -} from './rpc.schema.ts' - -// ---- Method registry and derived generics ---- -export type { RequestPayload, ResponseValue, RpcMethodMap } from './rpc-map.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts deleted file mode 100644 index e3e67a9c16..0000000000 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * RPC method registry and signature-derived generics. Map keys are the wire - * path segments of API Proxy unary calls. - */ - -import type { HostApi } from './host.ts' -import type { RpcResponse } from './rpc.ts' - -/** - * Method name → method signature. Signatures are the single source of truth; payload/value - * types are always derived from here. A method may declare a trailing AbortSignal after the - * request; the carrier passes its request signal, never a wire field. - */ -export interface RpcMethodMap { - 'host.describe': HostApi['describe'] -} - -/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ -export type RequestPayload = Parameters[0]['payload'] - -/** Business return value of method K (reaches through the RpcResponse narrow form to infer the ok value of result). */ -export type ResponseValue = - Awaited> extends RpcResponse ? T : never diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts deleted file mode 100644 index c8a322fced..0000000000 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Message-layer zod schemas for API Proxy unary calls and Host pushes. The - * payload slot is unknown in the full-form schemas — business payloads get a - * second parse dispatched by method (two-level parse discipline). Brand cast - * point: rpcIdSchema, and only there. - */ - -import { z } from 'zod' -import type { z as zCore } from 'zod' -type ZodIssue = zCore.core.$ZodIssue -import type { ClientRequest, RpcError, RpcId, ServerResponse } from './rpc.ts' - -/** - * Wire widening of a contract type: widens every property (deeply) to `original | undefined`. - * The repo enables exactOptionalPropertyTypes while zod `.optional()` outputs `T | undefined`, - * so `satisfies z.ZodType` is unusable across the board; anchoring is always - * written `satisfies z.ZodType>` — the widening only adds undefined, so - * missing fields / wrong types still fail to compile. On the JSON wire, "absent" and - * "value undefined" serialize identically, so the widening loses no validation semantics. - */ -export type Wire = T extends readonly (infer E)[] ? Wire[] - : T extends object ? { [K in keyof T]: Wire | undefined } - : T - -/** - * RpcId: one brand cast after schema validation (the only cast point in this - * file). No min-length: the id is an opaque echo token, and rejecting values - * here would only turn a correlatable error report into a client-side parse - * failure (the handler substitutes a sentinel when a request's id is unreadable). - */ -export const rpcIdSchema = z.string() as unknown as z.ZodType - -/** Error body: discriminated by code, per-branch details aligned to RpcErrorDetailsMap; details is required. */ -export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', [ - z.object({ code: z.literal('bad-request'), message: z.string(), details: z.object({ issues: z.array(z.custom()) }) }), - z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }), - z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }), - z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }), - z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), - z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }), - z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), - z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), - z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), - z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), -]) as unknown as z.ZodType - -/** - * Business success/failure result schema (generic, reusable). - * @param value - Schema for the business value. - * @returns Schema for RpcResult. - */ -export function rpcResultSchema(value: z.ZodType): z.ZodUnion { - return z.union([ - z.object({ ok: z.literal(true), value }), - z.object({ ok: z.literal(false), error: rpcErrorSchema }), - ]) -} - -// ---- Wire envelope schemas (payload/result.value stay wide for the second business parse) ---- -// The wide value slot is optional: a void business result serializes with no -// `value` field at all. Each endpoint's own second parse still requires its -// declared value, so absence never passes for a method that returns data. - -/** ClientRequest full form (payload stays wide — the business layer runs the second parse). */ -export const clientRequestSchema = z.object({ - type: z.literal('client-request'), - rpcId: rpcIdSchema, - method: z.string(), - payload: z.unknown(), -}) as unknown as z.ZodType - -/** ServerResponse full form (result.value stays wide). */ -export const serverResponseSchema = z.object({ - type: z.literal('server-response'), - rpcId: rpcIdSchema, - result: rpcResultSchema(z.unknown().optional()), -}) as unknown as z.ZodType - -/** Wire full-form union (discriminated by type). */ -export const rpcMessageSchema = z.discriminatedUnion('type', [ - clientRequestSchema as unknown as z.ZodObject, - serverResponseSchema as unknown as z.ZodObject, -]) diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts deleted file mode 100644 index d3d8643301..0000000000 --- a/packages/host/apiproxy/src/api/rpc.ts +++ /dev/null @@ -1,104 +0,0 @@ -/** - * API Proxy request and response message model. Logical messages remain - * independent of their physical carrier. - * api/ contract layer: zero Node dependencies, importable from the browser. - */ - -import type { z as zCore } from 'zod' -type ZodIssue = zCore.core.$ZodIssue -import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session/types' - -/** - * Message correlation id: the initiator mints it on a request; a response - * echoes the matching request's rpcId and never mints a new one. - */ -export type RpcId = Branded<'rpc-id'> - -/** - * Brands a string as RpcId (same precedent as core `SessionId()`). The Client - * mints each request id and the Host echoes it in the response. - * @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures). - * @returns The same string, branded (compile-time cast, zero runtime cost). - */ -export function RpcId(id: string): RpcId { - return id as RpcId -} - -/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */ -export interface RpcErrorDetailsMap { - 'bad-request': { issues: ZodIssue[] } - 'cancelled': {} - 'session-not-found': { sessionId: SessionId } - 'invalid-time-zone': { value: string } - 'agent-preset-read-only': { agentPreset: string; reason: string } - 'agent-preset-locked': { sessionId: SessionId; agentPreset: string } - 'agent-preset-not-found': { agentPreset: string; available: readonly string[] } - 'agent-preset-invalid': { agentPreset: string; reason: string } - 'agent-busy': { reason: string } - 'internal': {} -} - -/** Closed error-code union (the keys of RpcErrorDetailsMap). */ -export type RpcErrorCode = keyof RpcErrorDetailsMap - -/** - * Distributive union expanded from the map: code is the discriminant, so - * `switch (error.code)` narrows details. details is required (internal uses an explicit {}). - */ -export type RpcError = { - [C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] } -}[RpcErrorCode] - -/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */ -export type RpcResult = { ok: true; value: T } | { ok: false; error: RpcError } - -/** - * Fold a transport exception into the RpcResult error branch (unified error - * API; 'internal' as the catch-all code). Lives with RpcResult so every - * carrier consumer folds the same way. - * @param error - the thrown value from the carrier. - * @returns the error branch of an RpcResult. - */ -export function transportError(error: unknown): RpcResult { - return { - ok: false, - error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }, - } -} - -/** - * Signature-layer narrow form, request side (domain-interface view, shared by - * both directions): rpcId is explicit in the signature, never mixed into the - * business payload; the type tag and method are filled in by the carrier layer. - */ -export interface RpcRequest

    { - rpcId: RpcId - payload: P -} - -/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */ -export interface RpcResponse { - rpcId: RpcId - result: RpcResult -} - -// ---- Wire full forms ---- - -/** Call initiated by the client (wire carrier: POST /api/ body). */ -export interface ClientRequest { - type: 'client-request' - rpcId: RpcId - method: string - payload: unknown -} - -/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */ -export interface ServerResponse { - type: 'server-response' - rpcId: RpcId - result: RpcResult -} - -/** Authoritative wire full-form union; narrow via `switch (message.type)`. */ -export type RpcMessage = ClientRequest | ServerResponse diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts deleted file mode 100644 index 4c49240d24..0000000000 --- a/packages/host/apiproxy/src/fetch/client.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Client side of the fetch carrier. AbstractApiClient holds request correlation, - * envelope wrap/unwrap, zod parsing, and the payload-direct - * IApiClient domain methods (business code never mints). Platform differences ride two aspects: - * abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched. - */ - -import type { z } from 'zod' -import { randomUUID } from '@deepseek-ai/dsh-util-crypto' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts' -import { RpcId } from '../api/rpc.ts' -import type { Wire } from '../api/rpc.schema.ts' -import { serverResponseSchema } from '../api/rpc.schema.ts' -import { hostDescribeValueSchema } from '../api/host.schema.ts' - -/** - * Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary - * methods take the business payload directly — the carrier mints the rpcId and wraps the - * envelope. Business code needing the call's rpcId reads it from the RpcResponse echo. - * Unary methods accept an optional external AbortSignal as the last parameter. - * Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls - * carry only that external signal. In both cases the signal rides beside the request, never - * on the wire, like the stream signatures. - * Relationship: ApiProxy is the narrow-form signature contract the impl side implements; - * IApiClient is the payload-direct view clients consume; AbstractApiClient bridges the two. - * Derived per method key from RpcMethodMap so a map row addition updates this mechanically. - */ -export interface IApiClient { - host: { - describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise>> - } -} - -/** - * S→C second-level parse table: value schema by method (the response-path - * mirror of the handler's request table; key coverage compiler-enforced against RpcMethodMap). - */ -const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType>> } = { - 'host.describe': hostDescribeValueSchema, -} - -/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */ -const DEFAULT_TIMEOUT_MS = 30_000 - -/** URL base for in-process handler injection (fake authority, opencode precedent). */ -const INTERNAL_BASE = 'http://dsh.internal' - -/** - * Abstract fetch-carrier client. Subclasses supply the transport (doFetch) and may refine the - * per-message tap (onEnvelope) — platform aspects stay in subclasses, protocol invariants stay - * here. Envelope observation is a first-class aspect of this data middle layer: the instance - * owns a microtask-batched buffer (frame storms must not cost one consumer update per frame), - * and observers subscribe via subscribeEnvelopes. The isomorphic point survives: an in-process - * subclass whose doFetch is toFetchHandler(api).fetch never touches the network. - */ -export abstract class AbstractApiClient implements IApiClient { - /** Instance-owned observation buffer (module-level state would leak across instances/tests). */ - private envelopeBatch: RpcMessage[] = [] - private flushScheduled = false - private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>() - - /** @param timeoutMs - timeout for unary calls. */ - constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {} - - /** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */ - protected abstract doFetch(input: URL, init?: RequestInit): Promise - - /** - * Subscribe to batched envelope observation (diagnostics/logging consumers). - * Batches follow microtask boundaries; a listener throw is isolated (observation - * must never break the carrier). - * @param listener - receives each flushed batch in arrival order. - * @returns unsubscribe function. - */ - subscribeEnvelopes(listener: (batch: readonly RpcMessage[]) => void): () => void { - this.envelopeListeners.add(listener) - return () => { - this.envelopeListeners.delete(listener) - } - } - - /** Per-message tap: feeds the instance buffer. Subclasses may override to observe unbatched (call super to keep batching). */ - protected onEnvelope(message: RpcMessage): void { - if (this.envelopeListeners.size === 0) return - this.envelopeBatch.push(message) - if (this.flushScheduled) return - this.flushScheduled = true - queueMicrotask(() => { - this.flushScheduled = false - // Never empty here: a flush is only ever scheduled by the push above, - // and this callback is the sole drain point. - const batch = this.envelopeBatch - this.envelopeBatch = [] - for (const notify of this.envelopeListeners) { - try { - notify(batch) - } catch (error) { - console.error('[apiproxy] envelope listener threw:', error) - } - } - }) - } - - /** Browser = same-origin (a fake authority would fail DNS on real requests); no-location env (Node) = fake authority. */ - protected resolveBase(): string { - const loc = (globalThis as { location?: { origin?: string } }).location - return loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : INTERNAL_BASE - } - - protected mintRpcId(): RpcId { - // Not crypto.randomUUID: browsers withhold it outside secure contexts, - // and this base also mints on pages served over plain HTTP. - return RpcId(randomUUID()) - } - - /** - * Shared POST leg of unary calls: JSON body, - * default timeout merged with the caller's external signal, non-2xx → transport throw. - */ - private async postJson( - path: string, - body: ClientRequest, - signal: AbortSignal | undefined, - ): Promise { - const requestSignal = signal === undefined - ? AbortSignal.timeout(this.timeoutMs) - : AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal]) - const response = await this.doFetch(new URL(path, this.resolveBase()), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - signal: requestSignal, - }) - if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`) - return response - } - - /** - * Unary protocol path: mint → tap → POST full form → envelope parse → verify - * echo → value parse → tap → narrow. Virtual so a fake carrier (fixture) can - * override transport at this layer. - */ - protected async callUnary( - method: K, - payload: RequestPayload, - signal?: AbortSignal, - ): Promise>> { - const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload } - this.onEnvelope(message) - const response = await this.postJson(`/api/${method}`, message, signal) - const full = serverResponseSchema.parse(await response.json()) - this.onEnvelope(full) - if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`) - if (!full.result.ok) return { rpcId: full.rpcId, result: full.result } - // Second-level S→C parse: the ok value must match the method's Value schema (mirror of the - // handler's request-payload parse). The cast collapses the Wire<> widening, same as the handler side. - const value = UNARY_VALUE_SCHEMAS[method].parse(full.result.value) as ResponseValue - return { rpcId: full.rpcId, result: { ok: true, value } } - } - - // ---- IApiClient API (arrow properties so destructured/passed references stay bound) ---- - - readonly host: IApiClient['host'] = { - describe: (payload, signal) => this.callUnary('host.describe', payload, signal), - } - -} - -/** - * In-process client over an injected fetch-shaped handler (the isomorphic point: - * `new InProcessApiClient(toFetchHandler(api))` never touches the network). Lives here because - * in-process injection is this package's own capability (handler and client are both local). - */ -export class InProcessApiClient extends AbstractApiClient { - constructor(private readonly handler: { fetch: typeof fetch }, timeoutMs?: number) { - super(timeoutMs) - } - - /** - * Faithful to real fetch: reject on signal abort even when the in-process - * handler ignores the signal (a hung impl must not defeat timeout/cancel). - */ - protected doFetch(input: URL, init?: RequestInit): Promise { - const signal = init?.signal ?? undefined - if (signal === undefined) return this.handler.fetch(input, init) - if (signal.aborted) return Promise.reject(abortError(signal)) - return new Promise((resolve, reject) => { - const onAbort = (): void => { reject(abortError(signal)) } - signal.addEventListener('abort', onAbort, { once: true }) - this.handler.fetch(input, init) - .then(resolve, reject) - .finally(() => { signal.removeEventListener('abort', onAbort) }) - }) - } -} - -/** Mirror fetch's abort rejection: the signal's reason when present, else a DOMException-style AbortError. */ -function abortError(signal: AbortSignal): Error { - const reason: unknown = signal.reason - if (reason instanceof Error) return reason - if (typeof reason === 'string') return new Error(reason) - return new Error('This operation was aborted') -} diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts deleted file mode 100644 index 82142ef923..0000000000 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Server side of the fetch carrier: maps an ApiProxy onto a pure - * WHATWG Request->Response function. Two-level parse: full form (type/rpcId/method + - * path==method) -> payload dispatched per method. HTTP status expresses only the carrier - * (404 unknown path / 415 non-JSON media type / 400 non-JSON body / 500 handler crash); - * business errors are always 200 + ServerResponse. - */ - -import type { z } from 'zod' -import type { ApiProxy } from '../api/index.ts' -import { sessionLogQuerySchema } from '../api/downloads.schema.ts' -import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' -import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse } from '../api/rpc.ts' -import { RpcId } from '../api/rpc.ts' -import type { Wire } from '../api/rpc.schema.ts' -import { clientRequestSchema } from '../api/rpc.schema.ts' -import { hostDescribeRequestSchema } from '../api/host.schema.ts' - -/** - * Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a - * route row fails to compile, and each row's schema/invoke pair is checked against that row's - * payload type — a schema pasted onto the wrong row is a type error, not a runtime surprise. - * Schemas anchor to the Wire<> widening (the repo-wide exactOptionalPropertyTypes accommodation - * documented on Wire); the dispatch point carries the one Wire→exact cast. - * Every invoke receives the carrier Request's signal; routes whose contract - * declares a signal parameter forward it, and the rest ignore it. - */ -type UnaryRoutes = { - [K in keyof RpcMethodMap]: { - schema: z.ZodType>> - invoke(api: ApiProxy, request: RpcRequest>, signal: AbortSignal): Promise>> - } -} - -const UNARY_ROUTES: UnaryRoutes = { - 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, -} - -/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ -function methodFor(path: string): keyof RpcMethodMap | undefined { - return Object.hasOwn(UNARY_ROUTES, path) ? path as keyof RpcMethodMap : undefined -} - -/** - * Sentinel rpcId for error responses to envelopes whose own rpcId is unreadable: the response - * must still be a valid ServerResponse (a self-violating shape would turn the server's explicit - * bad-request report into a client-side parse failure). Fixed value, documented here as wire contract. - */ -const INVALID_REQUEST_RPC_ID = RpcId('invalid-request') - -/** Wrap a business error as a ServerResponse full form (rpcId backfilled; an unreadable rpcId uses the invalid-request sentinel). */ -function errorResponse(rpcId: RpcId, error: RpcError): Response { - const body: ServerResponse = { type: 'server-response', rpcId, result: { ok: false, error } } - return Response.json(body) -} - -/** Complete the impl's narrow form into a ServerResponse full form. */ -function fullResponse(narrow: RpcResponse): Response { - const body: ServerResponse = { type: 'server-response', rpcId: narrow.rpcId, result: narrow.result } - return Response.json(body) -} - -/** - * Parse the payload and invoke one unary route. Generic over the map key so - * the row's schema/invoke pairing typechecks; the only cast collapses the - * Wire<> widening back to the exact payload (undefined-valued properties and - * absent ones are indistinguishable after JSON transport). - */ -// K appears once in the signature but ties the UNARY_ROUTES[K] row lookup to its own -// schema/invoke pairing; a union parameter degrades the row to an uninvokable intersection. -// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -async function handleUnary( - api: ApiProxy, method: K, message: ClientRequest, signal: AbortSignal, -): Promise { - const route = UNARY_ROUTES[method] - const payload = route.schema.safeParse(message.payload) - if (!payload.success) { - return errorResponse(message.rpcId, { code: 'bad-request', message: `invalid payload for ${method}`, details: { issues: payload.error.issues } }) - } - try { - return fullResponse(await route.invoke(api, { rpcId: message.rpcId, payload: payload.data }, signal)) - } catch (error: unknown) { - // The impl never throws business errors; reaching here means the implementation itself crashed — 500, carrier layer. - return new Response(`handler failure: ${String(error)}`, { status: 500 }) - } -} - -/** - * Wraps an ApiProxy into a pure fetch function (isomorphic point: feed the returned fetch straight to InProcessApiClient). - * @param api - the host-side ApiProxy implementation. - * @returns an object holding `fetch(Request)`; paths outside /api/ return 404. - */ -export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { - return { - // Signature matches global fetch: the isomorphic point hands this function to InProcessApiClient as its transport aspect, - // Clients call in (url, init) form — normalize to Request before handling. - async fetch(input: RequestInfo | URL, init?: RequestInit): Promise { - const req = input instanceof Request ? input : new Request(input, init) - const url = new URL(req.url) - const path = url.pathname - - // No-envelope Host-only download channel: - // physical routes that answer directly, without a wire envelope. - if (path === '/api/session.export' && (req.method === 'GET' || req.method === 'HEAD')) { - // Query params are a different boundary from the POST envelope, but - // the request still casts its brands only through the domain schema. - const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) - if (!parsed.success) { - return new Response('missing or invalid sessionId query parameter', { status: 400 }) - } - const response = await api.downloads.sessionLog(parsed.data, req.signal) - if (req.method === 'GET') return response - await response.body?.cancel() - return new Response(null, { status: response.status, headers: response.headers }) - } - - if (req.method !== 'POST' || !path.startsWith('/api/')) { - return new Response('not found', { status: 404 }) - } - - // Cross-site write fence: browsers send "simple" POSTs (text/plain, - // form encodings) without a CORS preflight, so a malicious page could - // otherwise execute side-effectful RPCs blind — the response stays - // unreadable cross-origin, but the requested mutation would still run. Only the - // JSON media type is accepted; anything else is forced into a preflight - // this server never answers. 415 = carrier layer, like the 400 below. - const mediaType = req.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase() - if (mediaType !== 'application/json') { - return new Response('content type must be application/json', { status: 415 }) - } - - let body: unknown - try { - body = await req.json() - } catch { - // 400 = carrier layer (body is not even JSON); valid JSON with a bad shape goes 200 + bad-request. - return new Response('body is not JSON', { status: 400 }) - } - - const method = methodFor(path.slice('/api/'.length)) - if (method === undefined) return new Response('not found', { status: 404 }) - - const envelope = clientRequestSchema.safeParse(body) - if (!envelope.success) { - // Best effort at correlation: salvage a string rpcId from the raw body; - // otherwise the fixed sentinel keeps the response a valid ServerResponse. - const rawId = (body as { rpcId?: unknown } | null)?.rpcId - const rpcId = typeof rawId === 'string' ? RpcId(rawId) : INVALID_REQUEST_RPC_ID - return errorResponse(rpcId, { code: 'bad-request', message: 'invalid client-request message', details: { issues: envelope.error.issues } }) - } - const message: ClientRequest = envelope.data - if (message.method !== method) { - return errorResponse(message.rpcId, { code: 'bad-request', message: `method "${message.method}" does not match path "${method}"`, details: { issues: [] } }) - } - return handleUnary(api, method, message, req.signal) - }, - } -} diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts deleted file mode 100644 index 474ad6beef..0000000000 --- a/packages/host/apiproxy/src/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * @deepseek-ai/dsh-host-apiproxy — the API gateway every client shape shares: - * the ApiProxy contract (api/: types + zod schemas, browser-safe), the fetch - * carrier pair (fetch/: toFetchHandler on the host side, AbstractApiClient + - * platform subclasses on the client side), and the host-side implementation - * (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing - * `ctx.apiProxy`). Transport-agnostic by design: this package registers no - * routes — physical carriers wrap `ctx.apiProxy` themselves. - * - * The gateway consumes `ctx.agentDefaultModel` only for the deployment metadata - * returned by `host.describe`; Session Controller owns Session model selection. - */ - -import { Context, Service } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import type {} from '@deepseek-ai/dsh-agent-default-model' -import type { ApiProxy } from './api/index.ts' -import { createApiProxy } from './api-proxy.ts' -import { - DEFAULT_SESSION_LOG_COMPRESSION_LEVEL, - type SessionLogCompressionLevel, -} from './session-export.ts' - -export type * from './api/index.ts' -export { RpcId } from './api/rpc.ts' -export { toFetchHandler } from './fetch/handler.ts' -export { AbstractApiClient, InProcessApiClient } from './fetch/client.ts' -export type { IApiClient } from './fetch/client.ts' -export { createApiProxy } from './api-proxy.ts' -export type { ApiProxyDefaults } from './api-proxy.ts' - -declare module '@deepseek-ai/cordis' { - interface Context { - /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */ - apiProxy: ApiProxy - } -} - -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} - -/** - * The API gateway service: implements the ApiProxy contract over the composed - * host context and provides it as `ctx.apiProxy`. Its cwd metadata must match - * the default project directory supplied to Session Controller. - */ -export class ApiProxyService extends Service implements ApiProxy { - static inject = [ - 'agentDefaultModel', 'agents', 'attachments', 'sessions', 'sessionQuery', - ] - - static Config: z = z.object({ - nativeOpen: z.boolean(), - sessionExportCompressionLevel: z.number().step(1).min(0).max(9) - .default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z, - }) - - readonly host: ApiProxy['host'] - readonly downloads: ApiProxy['downloads'] - - constructor(ctx: Context, config: Config) { - super(ctx, 'apiProxy') - const api = createApiProxy(ctx, { - defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), - cwd: process.cwd(), - ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, - ...(config.sessionExportCompressionLevel === undefined - ? {} - : { sessionExportCompressionLevel: config.sessionExportCompressionLevel }), - }) - this.host = api.host - this.downloads = api.downloads - } -} - -export default ApiProxyService diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts deleted file mode 100644 index 9e9489aaff..0000000000 --- a/packages/host/apiproxy/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-host-apiproxy`. - * @module @deepseek-ai/dsh-host-apiproxy/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from '@deepseek-ai/cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy' - -/** Cordis companion plugin name. */ -export const name = 'host-apiproxy-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this package is the wire contract layer plus the - * host-side unary gateway over services owned elsewhere. rpcId round-trip and - * schema acceptance are enforced at the carrier boundary and exercised by the - * protocol-isomorphism suite. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts deleted file mode 100644 index 72b40a016d..0000000000 --- a/packages/host/apiproxy/src/session-export.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Host-side session-log download: streams one ZIP archive whose files are the - * sessions' stored artifact text verbatim plus every referenced media object. - * The root artifact sits under its original base name (`session.jsonl`); each - * subagent descendant under `subagents//`; each image referenced - * by any included log under `media/.` (content-addressed, - * so one archive never duplicates a shared image). No manifest is written — - * every file is byte-identical to the backend's durable artifact or attachment - * store and self-describing through its own header line or media type. Before - * each live session's artifact read, the SessionStore flush barrier makes the - * current in-memory log durable; cold sessions need no barrier. Request abort - * and response-consumer cancellation share one producer signal and terminate - * the active compressor. - * Compression runs on the host with fflate's streaming Zip API, so the archive - * bytes are produced incrementally and the host never holds the whole archive - * in one buffer; production waits for consumer pull whenever the response queue - * reaches its byte high-water mark, so a slow consumer bounds accumulation to - * the fixed 64 KiB response queue plus one synchronous fflate push. - * @module - */ - -import { Zip, ZipDeflate } from 'fflate' -import type { Context } from '@deepseek-ai/cordis' -import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionLineageNode, SessionQueryEngine } from '@deepseek-ai/dsh-session-query' -import type { SessionId, SessionStore } from '@deepseek-ai/dsh-session' -import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' - -/** Valid fflate DEFLATE levels accepted by session-log export. */ -export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - -/** Balanced default used when a direct createApiProxy caller omits deployment config. */ -export const DEFAULT_SESSION_LOG_COMPRESSION_LEVEL: SessionLogCompressionLevel = 6 - -/** The services a session-log export needs (the live-session store is optional). */ -export interface SessionLogExportDeps { - readonly sessionQuery: SessionQueryEngine | undefined - readonly sessionPersistence: SessionPersistence | undefined - readonly attachments: AttachmentStore | undefined - readonly sessions: SessionStore | undefined -} - -/** The export services narrowed to the mounted ones streaming actually reads. */ -export interface SessionLogExportReady { - readonly sessionQuery: SessionQueryEngine - readonly sessionPersistence: SessionPersistence - readonly attachments: AttachmentStore - readonly sessions: SessionStore | undefined -} - -/** - * Resolve the persistence, session-query, and attachment services a log export needs. - * @param ctx - the composed host context. - * @returns the export services (absent when the deployment does not mount them). - */ -export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { - return { - sessionQuery: ctx.get('sessionQuery'), - sessionPersistence: ctx.get('sessionPersistence'), - attachments: ctx.get('attachments'), - sessions: ctx.get('sessions'), - } -} - -/** - * Flush one currently live session through the store's authoritative durability - * barrier immediately before its raw artifact is read. A cold or absent id has - * no in-memory work to flush. - * @param deps - export services, including the optional live-session store. - * @param id - the session whose artifact is about to be read. - * @param signal - optional cancellation observed around the flush barrier. - */ -export async function flushLiveSessionLog( - deps: Pick, - id: SessionId, - signal?: AbortSignal, -): Promise { - signal?.throwIfAborted() - const sessions = deps.sessions - if (sessions === undefined) return - const session = sessions.get(id) - if (session === undefined) return - await sessions.flush(session) - signal?.throwIfAborted() -} - -/** One exported file: a stored artifact text or one referenced media object. */ -export type SessionLogZipEntry = - | { readonly path: string; readonly content: string } - | { readonly path: string; readonly data: Uint8Array } - -/** Zip extension for each accepted raster media type. */ -const MEDIA_TYPE_EXTENSIONS: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/webp': 'webp', - 'image/gif': 'gif', -} - -/** - * The zip path for one media object: content-addressed by the opaque - * attachment id so shared images land once and the id in the log maps back to - * the archive entry without a manifest. - * @param ref - the durable reference from a session log. - * @returns the archive path. - */ -function mediaEntryPath(ref: ImageAttachmentRef): string { - return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` -} - -/** - * Collect every image reference inside one content array, descending into - * nested tool results the way the live attachment route does. - * @param content - an event content array (or nested tool-result content). - * @param refs - the dedupe map being filled (keyed by attachment id). - */ -function collectImageRefs(content: unknown, refs: Map): void { - if (!Array.isArray(content)) return - const pending: unknown[] = [] - for (const item of content) pending.push(item) - while (pending.length > 0) { - const value = pending.pop() - if (typeof value !== 'object' || value === null || Array.isArray(value)) continue - const block = value as { type?: unknown; attachment?: unknown; content?: unknown } - if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { - const ref = block.attachment as ImageAttachmentRef - refs.set(String(ref.attachmentId), ref) - } - if (Array.isArray(block.content)) { - for (const item of block.content) pending.push(item) - } - } -} - -/** - * Collect every image reference one session event carries, across the same - * carriers the live attachment route scans (direct content, message content, - * inserted messages, and completed assistant chunk blocks). - * @param event - one parsed JSONL event object. - * @param refs - the dedupe map being filled (keyed by attachment id). - */ -function collectEventImageRefs(event: unknown, refs: Map): void { - const data = (event as { data?: unknown }).data - if (typeof data !== 'object' || data === null) return - const carrier = data as { - content?: unknown - message?: { content?: unknown } - inserted?: Array<{ content?: unknown }> - chunk?: { type?: unknown; block?: unknown } - } - collectImageRefs(carrier.content, refs) - if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) - if (carrier.inserted !== undefined) { - for (const message of carrier.inserted) collectImageRefs(message.content, refs) - } - if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs) -} - -/** - * Collect the distinct media references one stored artifact text names. - * Lines that fail to parse cannot reference media and are skipped (the - * artifact text itself is exported verbatim regardless). - * @param content - the stored artifact text. - * @returns the dedupe map keyed by attachment id. - */ -function imageRefsInArtifact(content: string): Map { - const refs = new Map() - for (const line of content.split('\n')) { - if (line === '') continue - let event: unknown - try { - event = JSON.parse(line) - } catch { - continue - } - collectEventImageRefs(event, refs) - } - return refs -} - -/** - * One safe zip path segment from an untrusted session id. Session ids are - * host-controlled, but the brand allows any non-empty string, so `../`, dot - * segments, and separator characters are neutralized before they can shape - * archive entries. Distinct ids may collapse onto one segment (id collision - * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). - * @param id - the raw session id. - * @returns a filesystem-safe single path segment. - */ -function safeSessionIdSegment(id: string): string { - return id.replace(/[^A-Za-z0-9_-]/g, '_') -} - -/** - * The export archive filename for one root session. - * @param sessionId - the root session id (sanitized to one safe path segment). - * @returns the attachment filename for the session's export archive. - */ -export function sessionLogZipFilename(sessionId: string): string { - return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` -} - -/** - * Yield the export entries in zip order: the preloaded root artifact first, - * then every subagent descendant in lineage order (each flushed when live, - * read from the persistence backend right before it is yielded, and dropped - * after the consumer moves on), then every distinct media object referenced by any of - * the included logs (read and verified from the attachment store, one archive - * entry per attachment id). The host holds at most one descendant's artifact - * text and one media object at a time beyond the root. - * @param deps - the mounted export services (the caller answered 500 before this runs). - * @param root - the already-read root artifact (read by the caller so the - * missing-session path can answer cleanly before streaming starts). - * @param sessionId - the root session id. - * @param includeDescendants - whether to include every subagent descendant. - * @param signal - optional cancellation forwarded to lineage, persistence, and attachment reads. - * @returns the export entries in zip order. - */ -export async function* sessionLogZipEntries( - deps: SessionLogExportReady, - root: SessionRawArtifact, - sessionId: SessionId, - includeDescendants: boolean, - signal?: AbortSignal, -): AsyncGenerator { - const media = new Map() - const rememberMedia = (content: string): void => { - for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) - } - rememberMedia(root.content) - yield { path: root.filename, content: root.content } - if (includeDescendants) { - const seen = new Set([sessionId]) - const collect = async function* ( - nodes: readonly SessionLineageNode[], - ): AsyncGenerator { - for (const node of nodes) { - signal?.throwIfAborted() - const id = node.session.header.id - if (seen.has(id)) continue - seen.add(id) - await flushLiveSessionLog(deps, id, signal) - const raw = await deps.sessionPersistence.readRaw(id, signal) - signal?.throwIfAborted() - if (raw === undefined) { - throw new Error(`subagent "${id}" has no stored log artifact`) - } - rememberMedia(raw.content) - yield { - path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, - content: raw.content, - } - yield* collect(node.descendants) - } - } - const lineage = await deps.sessionQuery.traceSession(sessionId, signal) - signal?.throwIfAborted() - yield* collect(lineage.descendants) - } - for (const ref of media.values()) { - signal?.throwIfAborted() - const stored = await deps.attachments.readImage(ref, signal) - signal?.throwIfAborted() - yield { path: mediaEntryPath(ref), data: stored.data } - } -} - -/** How many code units of artifact text one zip push carries (bounded encode memory). */ -const PUSH_CHUNK_CODE_UNITS = 1 << 16 - -/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */ -const PUSH_CHUNK_BYTES = 1 << 16 - -/** Byte capacity retained by the response stream before ZIP production waits for pull. */ -const RESPONSE_HIGH_WATER_MARK_BYTES = 1 << 16 - -/** One producer waiter released only when ReadableStream pull restores capacity. */ -class ResponseCapacityGate { - private releasePending: (() => void) | undefined - - /** - * Wait until the response queue has positive byte capacity or cancellation wins. - * @param controller - response controller whose desired size owns capacity. - * @param signal - combined request/consumer cancellation. - */ - async wait( - controller: ReadableStreamDefaultController, - signal: AbortSignal, - ): Promise { - signal.throwIfAborted() - if (controller.desiredSize === null || controller.desiredSize > 0) return - await new Promise((resolve) => { - const release = (): void => { - this.releasePending = undefined - signal.removeEventListener('abort', release) - resolve() - } - this.releasePending = release - signal.addEventListener('abort', release, { once: true }) - }) - signal.throwIfAborted() - } - - /** Release the current producer waiter after a consumer pull. */ - pulled(): void { - this.releasePending?.() - } -} - -/** - * Push one media object's bytes into a deflate stream in bounded chunks, - * waiting for consumer capacity between chunks like the artifact path does. - * @param deflate - the zip entry's deflate stream. - * @param data - the stored image bytes. - * @param controller - response queue controller. - * @param capacity - pull-driven response-capacity gate. - * @param signal - cancellation; throws when aborted. - */ -async function pushBinaryChunks( - deflate: ZipDeflate, - data: Uint8Array, - controller: ReadableStreamDefaultController, - capacity: ResponseCapacityGate, - signal: AbortSignal, -): Promise { - let offset = 0 - do { - signal.throwIfAborted() - const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength) - const finalChunk = end >= data.byteLength - deflate.push(data.subarray(offset, end), finalChunk) - offset = end - await capacity.wait(controller, signal) - } while (offset < data.byteLength) -} - -/** - * Push one artifact's text into a deflate stream in bounded chunks, never - * splitting a surrogate pair across a chunk boundary (a lone high surrogate - * re-encodes as U+FFFD and would silently corrupt the exported artifact). - * @param deflate - the zip entry's deflate stream. - * @param content - the artifact text verbatim. - * @param controller - response queue controller. - * @param capacity - pull-driven response-capacity gate. - * @param signal - cancellation; throws when aborted. - */ -async function pushArtifactChunks( - deflate: ZipDeflate, - content: string, - controller: ReadableStreamDefaultController, - capacity: ResponseCapacityGate, - signal: AbortSignal, -): Promise { - const encoder = new TextEncoder() - let offset = 0 - let finalChunk: boolean - do { - signal.throwIfAborted() - let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) - if (end < content.length && end - offset > 1) { - // Back off one code unit when the boundary lands inside a surrogate - // pair: the pair then starts the next chunk whole. - const last = content.charCodeAt(end - 1) - if (last >= 0xd800 && last <= 0xdbff) end -= 1 - } - finalChunk = end >= content.length - deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) - offset = end - await capacity.wait(controller, signal) - } while (!finalChunk) -} - -/** - * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is - * read and validated by the caller before this is called (missing root or - * missing services answer cleanly before any byte is produced); each entry is - * then encoded and deflated in bounded chunks as it is produced, so the - * archive bytes arrive incrementally. A descendant that fails to read errors - * the stream (fail-loud, never silent under-export). - * @param deps - the mounted export services (the caller answered 500 before this runs). - * @param root - the already-read root artifact (first zip entry). - * @param sessionId - the root session id. - * @param includeDescendants - whether to include every subagent descendant. - * @param compressionLevel - validated fflate DEFLATE level for every ZIP entry. - * @param signal - request cancellation combined with response-consumer cancellation. - * @returns the zip byte stream. - */ -export function streamSessionLogZip( - deps: SessionLogExportReady, - root: SessionRawArtifact, - sessionId: SessionId, - includeDescendants: boolean, - compressionLevel: SessionLogCompressionLevel, - signal: AbortSignal, -): ReadableStream { - const consumerAbort = new AbortController() - const producerSignal = AbortSignal.any([signal, consumerAbort.signal]) - let zip: Zip | undefined - let zipTerminated = false - const capacity = new ResponseCapacityGate() - const terminateZip = (): void => { - if (zip === undefined || zipTerminated) return - zipTerminated = true - zip.terminate() - } - return new ReadableStream({ - start(controller) { - // fflate invokes the callback synchronously per compressed chunk, so a - // single push can enqueue ahead of a slow consumer; the capacity gate - // waits for pull between pushes once the byte queue is full, bounding - // accumulation to the queue high-water mark plus one synchronous push. - const archive = new Zip((error, data, final) => { - /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ - if (error) { - controller.error(error) - return - } - /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ - if (data.byteLength > 0) controller.enqueue(data) - if (final) controller.close() - }) - zip = archive - void (async () => { - try { - for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, producerSignal)) { - const deflate = new ZipDeflate(entry.path, { level: compressionLevel }) - archive.add(deflate) - if ('content' in entry) { - await pushArtifactChunks(deflate, entry.content, controller, capacity, producerSignal) - } else { - await pushBinaryChunks(deflate, entry.data, controller, capacity, producerSignal) - } - } - archive.end() - } catch (error) { - // A mid-stream failure (missing descendant, cancellation, read - // error) must fail the download rather than ship a truncated archive. - /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ - terminateZip() - controller.error(error instanceof Error ? error : new Error(String(error))) - } - })() - }, - pull() { - capacity.pulled() - }, - cancel(reason) { - consumerAbort.abort( - reason instanceof Error ? reason : new Error('session log export stream cancelled'), - ) - terminateZip() - }, - }, { - highWaterMark: RESPONSE_HIGH_WATER_MARK_BYTES, - size: chunk => chunk.byteLength, - }) -} diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts deleted file mode 100644 index f6ed7916b2..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * Settings events consumed by Client model and permission surfaces. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import z from '@deepseek-ai/schemastery' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import SystemPrompt from '@deepseek-ai/dsh-system-prompt' -import ToolRuntime from '@deepseek-ai/dsh-tools' -import { SettingsProvider, settingsNamespace } from '@deepseek-ai/dsh-settings' -import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' -import { CredentialProvider } from '@deepseek-ai/dsh-credentials' -import type { - CredentialInfo, - CredentialKey, - CredentialRecord, - CredentialRecordEntry, - CredentialRecordInfo, - CredentialRef, - ResolvedCredential, -} from '@deepseek-ai/dsh-credentials' -import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' - -/** In-memory settings provider: the Service Definition base class owns all tested behavior. */ -class MemorySettings extends SettingsProvider { - doc: Record - - constructor(ctx: ConstructorParameters[0], options?: { - doc?: Record - readOnly?: boolean - documentPath?: string - preparedPath?: string - }) { - super(ctx) - this.doc = structuredClone(options?.doc ?? {}) - this.readOnly = options?.readOnly ?? false - this.path = options?.documentPath - this.preparedPath = options?.preparedPath - } - - private readonly readOnly: boolean - private readonly path: string | undefined - private readonly preparedPath: string | undefined - - get writable(): boolean { - return !this.readOnly - } - - override get documentPath(): string | undefined { - return this.path - } - - override prepareDocument(): Promise { - return Promise.resolve(this.preparedPath ?? this.documentPath) - } - - protected load(): Promise> { - return Promise.resolve(structuredClone(this.doc)) - } - - protected persist(ns: SettingsNamespace, section: Record): Promise { - this.doc[ns] = structuredClone(section) - return Promise.resolve() - } -} - -/** In-memory credential provider with an env-shadow double for the rejection path. */ -class MemoryCredentials extends CredentialProvider { - private readonly values = new Map() - - constructor(ctx: ConstructorParameters[0], options?: { shadowed?: string[] }) { - super(ctx) - this.shadowed = new Set(options?.shadowed ?? []) - } - - private readonly shadowed: Set - - resolve(ref: CredentialRef): Promise { - if (this.shadowed.has(ref)) return Promise.resolve({ value: 'from-env', source: 'env' }) - const value = this.values.get(ref) - return Promise.resolve(value === undefined ? undefined : { value, source: 'file' }) - } - - describe(ref: CredentialRef): Promise { - if (this.shadowed.has(ref)) return Promise.resolve({ configured: true, source: 'env', writable: false }) - const configured = this.values.has(ref) - return Promise.resolve({ configured, ...configured ? { source: 'file' } : {}, writable: true }) - } - - set(ref: CredentialRef, value: string): Promise { - if (this.shadowed.has(ref)) { - return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`)) - } - this.values.set(ref, value) - this.ctx.emit('credentials/reference-updated', ref) - return Promise.resolve() - } - - unset(ref: CredentialRef): Promise { - if (this.shadowed.has(ref)) { - return Promise.reject(new Error(`credentials: ${ref} is shadowed by the read-only environment`)) - } - this.values.delete(ref) - this.ctx.emit('credentials/reference-updated', ref) - return Promise.resolve() - } - - // The record half has no wire face on this proxy, so the double answers the - // empty store rather than modelling storage the tests never exercise. - readRecord(): Promise { - return Promise.resolve(undefined) - } - - describeRecord(): Promise { - return Promise.resolve({ configured: false, writable: true }) - } - - listRecords(): Promise { - return Promise.resolve([]) - } - - modifyRecord( - _key: CredentialKey, - mutate: (current: CredentialRecord | undefined) => Promise, - ): Promise { - return mutate(undefined) - } - - deleteRecord(): Promise { - return Promise.resolve() - } -} - -const NS = settingsNamespace('llm-deepseek') - -const AdapterConfig = z.object({ - apiKey: z.string().role('secret'), - apiKeyEnv: z.string().default('DEEPSEEK_API_KEY'), - baseURL: z.string(), -}) - -async function harness(options?: { - settings?: false | { - doc?: Record - readOnly?: boolean - documentPath?: string - preparedPath?: string - } - credentials?: false | { shadowed?: string[] } -}): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRuntime) - await ctx.plugin(AgentRegistry) - if (options?.settings !== false) await ctx.plugin(MemorySettings, options?.settings) - if (options?.credentials !== false) await ctx.plugin(MemoryCredentials, options?.credentials) - return ctx -} - -/** Observe settings commits while one API operation runs. */ -async function captureSettingsUpdates( - ctx: Context, - run: () => Promise, -): Promise> { - const updates: Array = [] - const dispose = ctx.on('settings/document-updated', (namespace, revision) => { - updates.push([namespace, revision]) - }) - try { - await run() - return updates - } finally { - dispose() - } -} - -/** Expected settings event tuple with its owner-assigned revision. */ -function expectedSettingsUpdate(ns: string): readonly unknown[] { - return [ns, expect.any(Number)] -} - -describe('settings events', () => { - it('forwards a provider settings change for model-catalog consumers', async () => { - // Editing `models` changes no route, so llm/adapters-updated never fires - // and an open model picker would keep serving the stale catalog. Storing - // an override equal to the resolved value emits nothing on - // settings/updated, so another tab would never learn the field became - // overridden. - const ctx = await harness() - ctx.settings.register(NS, AdapterConfig, { base: { baseURL: 'https://base' } }) - const updates = await captureSettingsUpdates(ctx, async () => { - await ctx.settings.update(settingsNamespace('llm-deepseek'), { baseURL: 'https://base' }) - }) - expect(updates).toEqual([expectedSettingsUpdate('llm-deepseek')]) - // The resolved value never moved: base already said https://base. - expect(ctx.settings.describe().find(view => String(view.ns) === 'llm-deepseek')?.value) - .toEqual({ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' }) - }) - - it('broadcasts a permission change without invalidating the model catalog', async () => { - const ctx = await harness() - const permission = ctx.settings.register(settingsNamespace('permission'), z.object({ - defaultPreset: z.union(['read-only', 'workspace-write']).required(), - }), { - base: { defaultPreset: 'read-only' }, - }) - const updates = await captureSettingsUpdates(ctx, async () => { - await permission.update({ defaultPreset: 'workspace-write' }) - }) - expect(updates).toEqual([expectedSettingsUpdate('permission')]) - }) - - it('forwards an Agent-default settings change for model-catalog consumers', async () => { - const ctx = await harness() - const defaultModel = ctx.settings.register(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE, z.object({ - provider: z.string().required(), - model: z.string().required(), - }), { base: { provider: 'deepseek-official', model: 'deepseek-v4-flash' } }) - // The shared section names the selection every blank session resolves to, - // so an externally edited default — another tab, a - // hand-edited settings.yaml — has to reach an open selector as well. - const updates = await captureSettingsUpdates(ctx, async () => { - await defaultModel.replace({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) - }) - expect(updates).toEqual([expectedSettingsUpdate('agent-default-model')]) - }) - - - - - - -}) diff --git a/packages/host/apiproxy/tests/api-proxy-host.spec.ts b/packages/host/apiproxy/tests/api-proxy-host.spec.ts deleted file mode 100644 index 180be46507..0000000000 --- a/packages/host/apiproxy/tests/api-proxy-host.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { homedir } from 'node:os' -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' -import { createApiProxy } from '../src/api-proxy.ts' - -let nextRpc = 1 -const contexts: Context[] = [] - -afterEach(async () => { - await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) -}) - -function request

    (payload: P): RpcRequest

    { - return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload } -} - -function expectOk(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T { - expect(response.result.ok).toBe(true) - if (!response.result.ok) throw new Error('unreachable') - return response.result.value -} - -async function harness( - extras: { - canOpenPath?: () => boolean - } = {}, -) { - const ctx = new Context() - contexts.push(ctx) - await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), - cwd: '/tmp/dsh-apiproxy-host', - ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath }, - }) - return { api } -} - -describe('host.describe', () => { - it('describes whether the deployment can reach a native desktop', async () => { - const visible = await harness({ canOpenPath: () => true }) - const headless = await harness({ canOpenPath: () => false }) - expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true) - expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false) - expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir()) - }) - -}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts deleted file mode 100644 index e587346061..0000000000 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Wire-protocol coverage over the isomorphic point: InProcessApiClient → - * toFetchHandler(scripted impl) runs the real envelope wrap/unwrap, zod - * two-level parse, and rpcId discipline with no network or browser. Each case - * scripts its own minimal ApiProxy. - */ - -import { describe, expect, it, vi } from 'vitest' -import type { ApiProxy, RpcMessage, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy' -import { InProcessApiClient, RpcId, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' - -function ok(request: RpcRequest, value: T): Promise> { - return Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value } }) -} - -/** Scripted impl: every method resolves an empty-ish OK unless a case overrides it. */ -function scriptedApi(overrides: { - host?: Partial -} = {}): ApiProxy { - return { - host: { - describe: r => ok(r, { - version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, - }), - ...overrides.host, - }, - downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, - } -} - -function client(api: ApiProxy, timeoutMs?: number): InProcessApiClient { - return new InProcessApiClient(toFetchHandler(api), timeoutMs) -} - -describe('unary round trip', () => { - it('carries payload out and value back through the full wire form', async () => { - let seen: RpcRequest<{}> | undefined - const api = scriptedApi({ - host: { - describe: (request) => { - seen = request - return ok(request, { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true }) - }, - }, - }) - const response = await client(api).host.describe({}) - expect(seen?.payload).toEqual({}) - expect(seen?.rpcId).toBeTruthy() - expect(response.rpcId).toBe(seen?.rpcId) - expect(response.result).toMatchObject({ ok: true, value: { version: '0-test' } }) - }) - - it('passes business errors through as 200 + err result, not a throw', async () => { - const api = scriptedApi({ - host: { - describe: request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: false, error: { code: 'internal', message: 'nope', details: {} } }, - }), - }, - }) - const response = await client(api).host.describe({}) - expect(response.result).toEqual({ ok: false, error: { code: 'internal', message: 'nope', details: {} } }) - }) - - it('throws on rpcId echo mismatch', async () => { - const api = scriptedApi({ - host: { - describe: () => Promise.resolve({ - rpcId: RpcId('forged'), - result: { ok: true, value: { version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true } }, - }), - }, - }) - await expect(client(api).host.describe({})).rejects.toThrow(/rpcId mismatch/) - }) - - it('rejects a malformed envelope as bad-request, salvaging the rpcId or falling back to the sentinel', async () => { - const handler = toFetchHandler(scriptedApi()) - // No salvageable rpcId → the fixed invalid-request sentinel keeps the response a valid ServerResponse. - const noId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nonsense: true }) }) - expect(noId.status).toBe(200) - const noIdParsed = await noId.json() as { rpcId: string; result: { ok: boolean } } - expect(noIdParsed.result.ok).toBe(false) - expect(noIdParsed.rpcId).toBe('invalid-request') - // A string rpcId in the otherwise-bad body is salvaged for correlation. - const withId = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rpcId: 'salvage-me', nonsense: true }) }) - const withIdParsed = await withId.json() as { rpcId: string; result: { ok: boolean } } - expect(withIdParsed.result.ok).toBe(false) - expect(withIdParsed.rpcId).toBe('salvage-me') - }) - - it('maps carrier failures to HTTP statuses and the client throws transport failure', async () => { - const handler = toFetchHandler(scriptedApi()) - // Unknown method → 404. - const notFound = await handler.fetch('http://dsh.internal/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }) - expect(notFound.status).toBe(404) - // Non-JSON body → 400. - const badBody = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{oops' }) - expect(badBody.status).toBe(400) - // Impl crash → 500, and through the client that is a throw, not an err result. - const crashing = scriptedApi({ host: { describe: () => { throw new Error('impl exploded') } } }) - await expect(client(crashing).host.describe({})).rejects.toThrow(/transport failure .*500/) - }) - - it('rejects non-JSON media types before executing anything (cross-site simple-request fence)', async () => { - const describe = vi.fn((request: RpcRequest<{}>) => ok(request, { - version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true, - })) - const handler = toFetchHandler(scriptedApi({ host: { describe } })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} }) - // A "simple" browser POST (text/plain — sent with no CORS preflight) is - // refused at the carrier before the impl runs. - const plain = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'text/plain' }, body }) - expect(plain.status).toBe(415) - // A string body with no explicit header defaults to text/plain — same fence. - const unlabelled = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', body }) - expect(unlabelled.status).toBe(415) - expect(describe).not.toHaveBeenCalled() - // Media-type parameters pass: the fence checks the type, not the exact string. - const charset = await handler.fetch('http://dsh.internal/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json; charset=utf-8' }, body }) - expect(charset.status).toBe(200) - expect(describe).toHaveBeenCalledTimes(1) - }) - - it('rejects when the transport never resolves within timeoutMs', async () => { - // AbortSignal.timeout is immune to fake timers; a short real timeout keeps this fast. - const never = new InProcessApiClient({ - fetch: (_i: RequestInfo | URL, init?: RequestInit) => new Promise((_resolve, reject) => { - init?.signal?.addEventListener('abort', () => { reject(new Error('aborted by timeout')) }) - }), - }, 25) - await expect(never.host.describe({})).rejects.toThrow() - }) - - it('aborts a unary call through the caller-supplied external signal', async () => { - // Real-fetch semantics: on abort the rejection is the signal's reason, and the abort - // works even when the transport ignores the signal entirely (hung impl). - const gate = new AbortController() - const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.host.describe({}, gate.signal) - gate.abort(new Error('externally aborted')) - await expect(call).rejects.toThrow(/externally aborted/) - }) - - it('rejects an already-aborted signal before touching the transport, mapping a string reason to an Error', async () => { - let touched = false - const c = new InProcessApiClient({ - fetch: () => { - touched = true - return Promise.resolve(new Response('{}')) - }, - }, 60_000) - const gate = new AbortController() - gate.abort('gone before start') - await expect(c.host.describe({}, gate.signal)).rejects.toThrow('gone before start') - expect(touched).toBe(false) - }) - - it('maps a non-Error, non-string abort reason to the default AbortError message', async () => { - const gate = new AbortController() - const hung = new InProcessApiClient({ fetch: () => new Promise(() => {}) }, 60_000) - const call = hung.host.describe({}, gate.signal) - gate.abort(42) - await expect(call).rejects.toThrow('This operation was aborted') - }) - - it('passes a signal-less doFetch straight through to the handler', async () => { - class Probe extends InProcessApiClient { - direct(url: URL): Promise { - return this.doFetch(url) - } - } - const probe = new Probe({ fetch: () => Promise.resolve(new Response('raw')) }) - const response = await probe.direct(new URL('http://dsh.internal/probe')) - expect(await response.text()).toBe('raw') - }) - - it('throws on an S→C ok value that fails the method value schema (second-level parse)', async () => { - // Impl echoes rpcId but returns a wrong-shaped value: envelope parse passes, value parse must reject. - const api = scriptedApi({ - host: { describe: request => Promise.resolve({ rpcId: request.rpcId, result: { ok: true, value: { version: 1 } } }) as never }, - }) - await expect(client(api).host.describe({})).rejects.toThrow() - }) -}) - -describe('envelope tap', () => { - it('delivers one microtask batch of full forms per unary call', async () => { - const api = scriptedApi() - const tapped = client(api) - const batches: (readonly RpcMessage[])[] = [] - tapped.subscribeEnvelopes(batch => batches.push(batch)) - await tapped.host.describe({}) - await vi.waitFor(() => { expect(batches.length).toBeGreaterThan(0) }) - const all = batches.flat() - expect(all.map(m => m.type)).toEqual(['client-request', 'server-response']) - expect(all[0]?.rpcId).toBe(all[1]?.rpcId) - }) - - it('isolates a throwing listener and keeps serving the call', async () => { - const api = scriptedApi() - const tapped = client(api) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - try { - const good: string[] = [] - tapped.subscribeEnvelopes(() => { throw new Error('listener bug') }) - tapped.subscribeEnvelopes(batch => good.push(...batch.map(m => m.type))) - const response = await tapped.host.describe({}) - expect(response.result.ok).toBe(true) - await vi.waitFor(() => { expect(good).toContain('server-response') }) - } finally { - errorSpy.mockRestore() - } - }) - - it('buffers nothing with zero subscribers and unsubscribes cleanly', async () => { - const api = scriptedApi() - const tapped = client(api) - await tapped.host.describe({}) // no subscribers: must not accumulate - const batches: (readonly RpcMessage[])[] = [] - const unsubscribe = tapped.subscribeEnvelopes(batch => batches.push(batch)) - unsubscribe() - await tapped.host.describe({}) - await new Promise(resolve => setTimeout(resolve, 0)) - expect(batches).toEqual([]) - }) -}) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts deleted file mode 100644 index 844c4d5039..0000000000 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ApiProxy } from '../src/api/index.ts' -import type { RpcMessage } from '../src/api/rpc.ts' -import { toFetchHandler } from '../src/fetch/handler.ts' -import { AbstractApiClient, InProcessApiClient } from '../src/fetch/client.ts' - -/** Minimal in-memory ApiProxy that echoes rpcIds. */ -function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy { - return { - host: { - async describe(request) { - if (overrides.crashOn === 'host.describe') throw new Error('impl crashed') - return { - rpcId: request.rpcId, - result: { - ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, - }, - } - }, - }, - downloads: { - async sessionLog() { - return new Response('stub', { status: 404 }) - }, - }, - } -} - -function client(api: ApiProxy = fakeApi(), timeoutMs?: number): InProcessApiClient { - return new InProcessApiClient(toFetchHandler(api), timeoutMs) -} - -describe('unary round trip (handler ⇄ client, no network)', () => { - it('carries a success result and echoes the minted rpcId', async () => { - const response = await client().host.describe({}) - expect(response.result).toMatchObject({ ok: true, value: { version: 'v', cwd: '/w' } }) - expect(response.rpcId).toMatch(/[0-9a-f-]{36}/) - }) - - it('carries a business error as 200 + error result', async () => { - const api = fakeApi() - api.host.describe = request => Promise.resolve({ - rpcId: request.rpcId, - result: { ok: false, error: { code: 'internal', message: 'stub', details: {} } }, - }) - const response = await client(api).host.describe({}) - expect(response.result.ok).toBe(false) - if (!response.result.ok) expect(response.result.error.code).toBe('internal') - }) - -}) - -describe('handler carrier-layer statuses', () => { - const handler = toFetchHandler(fakeApi()) - - it('404s unknown paths and non-POST non-stream methods', async () => { - expect((await handler.fetch(new Request('http://x/other', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/host.describe', { method: 'GET' }))).status).toBe(404) - expect((await handler.fetch(new Request('http://x/api/no.such', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ type: 'client-request', rpcId: 'r', method: 'no.such', payload: {} }) }))).status).toBe(404) - }) - - it('400s a non-JSON body', async () => { - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: 'not json' })) - expect(response.status).toBe(400) - }) - - it('rejects a malformed envelope with bad-request and the invalid-request sentinel rpcId', async () => { - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ nope: true }) })) - expect(response.status).toBe(200) - const body = await response.json() as { rpcId: string; result: { ok: boolean; error?: { code: string } } } - expect(body.rpcId).toBe('invalid-request') - expect(body.result.error?.code).toBe('bad-request') - }) - - it('rejects an invalid payload with the zod issues attached', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-10', method: 'host.describe', payload: null }) - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - const parsed = await response.json() as { result: { error?: { code: string; details: { issues: unknown[] } } } } - expect(parsed.result.error?.code).toBe('bad-request') - expect(parsed.result.error?.details.issues.length).toBeGreaterThan(0) - }) - - it('rejects a request whose envelope method does not match its path', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-mismatch', method: 'other.method', payload: {} }) - const response = await handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - const parsed = await response.json() as { result: { error?: { code: string; message: string } } } - expect(parsed.result.error).toMatchObject({ - code: 'bad-request', - message: 'method "other.method" does not match path "host.describe"', - }) - }) - - it('500s when the impl itself throws', async () => { - const crashing = toFetchHandler(fakeApi({ crashOn: 'host.describe' })) - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-11', method: 'host.describe', payload: {} }) - const response = await crashing.fetch(new Request('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body })) - expect(response.status).toBe(500) - expect(await response.text()).toContain('impl crashed') - }) - - it('accepts (url, init) form fetch invocation', async () => { - const body = JSON.stringify({ type: 'client-request', rpcId: 'r-12', method: 'host.describe', payload: {} }) - const response = await handler.fetch('http://x/api/host.describe', { method: 'POST', headers: { 'content-type': 'application/json' }, body }) - expect(response.status).toBe(200) - }) -}) - -describe('client transport failures', () => { - it('throws on a non-OK unary transport', async () => { - const broken = new InProcessApiClient({ fetch: async () => new Response('down', { status: 503 }) }) - await expect(broken.host.describe({})).rejects.toThrow('transport failure for /api/host.describe: HTTP 503') - }) - - it('throws on an rpcId echo mismatch', async () => { - const lying = new InProcessApiClient({ - fetch: async () => Response.json({ - type: 'server-response', - rpcId: 'someone-else', - result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true } }, - }), - }) - await expect(lying.host.describe({})).rejects.toThrow('rpcId mismatch') - }) -}) - -describe('envelope observation', () => { - it('batches envelopes per microtask and isolates a throwing listener', async () => { - const c = client() - const batches: (readonly RpcMessage[])[] = [] - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const unsubscribeThrowing = c.subscribeEnvelopes(() => { throw new Error('observer bug') }) - const unsubscribe = c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await c.host.describe({}) - await new Promise((resolve) => { setTimeout(resolve, 0) }) - // request and response tap in separate microtask windows (the await between - // them yields), so both arrive but batch count is timing-defined - expect(batches.flatMap(batch => batch.map(message => message.type))).toEqual(['client-request', 'server-response']) - expect(errorSpy).toHaveBeenCalled() - unsubscribe() - unsubscribeThrowing() - errorSpy.mockRestore() - }) - - it('skips buffering entirely with no listeners and after unsubscribe', async () => { - const c = client() - const seen: RpcMessage[] = [] - const unsubscribe = c.subscribeEnvelopes((batch) => { seen.push(...batch) }) - unsubscribe() - await c.host.describe({}) - await new Promise((resolve) => { setTimeout(resolve, 0) }) - expect(seen).toHaveLength(0) - }) - - it('coalesces multiple calls in one microtask window into one flush', async () => { - const c = client() - const batches: (readonly RpcMessage[])[] = [] - c.subscribeEnvelopes((batch) => { batches.push(batch) }) - await Promise.all([c.host.describe({}), c.host.describe({})]) - await new Promise((resolve) => { setTimeout(resolve, 0) }) - const total = batches.reduce((n, batch) => n + batch.length, 0) - expect(total).toBe(4) - }) -}) - -describe('resolveBase', () => { - it('prefers a real location.origin and falls back to the internal authority', async () => { - class Probe extends AbstractApiClient { - urls: string[] = [] - protected async doFetch(input: URL): Promise { - this.urls.push(input.href) - return Response.json({ - type: 'server-response', - rpcId: this.lastMinted, - result: { - ok: true, - value: { version: 'v', cwd: '/w', attachedSessions: 0, home: '/h', canOpenPath: true }, - }, - }) - } - - lastMinted = '' - protected override mintRpcId(): ReturnType { - const id = super.mintRpcId() - this.lastMinted = id - return id - } - } - const probe = new Probe() - await probe.host.describe({}) - expect(probe.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) - - const globalWithLocation = globalThis as { location?: { origin?: string } } - globalWithLocation.location = { origin: 'http://host.example' } - try { - const probe2 = new Probe() - await probe2.host.describe({}) - expect(probe2.urls[0]).toMatch(/^http:\/\/host\.example\//) - globalWithLocation.location = { origin: 'null' } // sandboxed iframe shape - const probe3 = new Probe() - await probe3.host.describe({}) - expect(probe3.urls[0]).toMatch(/^http:\/\/dsh\.internal\//) - } finally { - delete globalWithLocation.location - } - }) -}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts deleted file mode 100644 index 33cf4b2eca..0000000000 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { RpcId, transportError } from '../src/api/rpc.ts' -import { - clientRequestSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, - rpcResultSchema, serverResponseSchema, -} from '../src/api/rpc.schema.ts' -import { z } from 'zod' -import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' - -describe('RpcId', () => { - it('brands a raw string at zero runtime cost', () => { - expect(RpcId('abc')).toBe('abc') - expect(rpcIdSchema.parse('abc')).toBe('abc') - // No min-length: the id is an opaque echo token (see rpcIdSchema's contract). - expect(rpcIdSchema.parse('')).toBe('') - expect(() => rpcIdSchema.parse(42)).toThrow() - }) -}) - -describe('transportError', () => { - it('folds Error and non-Error throws into the internal error branch', () => { - expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) - expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) - }) -}) - -describe('rpcErrorSchema', () => { - it('accepts every code branch with its required details', () => { - expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') - expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled') - expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found') - expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone') - expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only') - expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked') - expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found') - expect(rpcErrorSchema.parse({ code: 'agent-preset-invalid', message: 'm', details: { agentPreset: 'p', reason: 'bad' } }).code).toBe('agent-preset-invalid') - expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') - expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') - }) - - it('rejects a known code with missing details', () => { - expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow() - expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow() - }) -}) - -describe('rpcResultSchema', () => { - it('accepts both result branches and rejects hybrids', () => { - const schema = rpcResultSchema(z.object({ n: z.number() })) - expect(schema.parse({ ok: true, value: { n: 1 } })).toEqual({ ok: true, value: { n: 1 } }) - const err = schema.parse({ ok: false, error: { code: 'internal', message: 'x', details: {} } }) - expect(err).toMatchObject({ ok: false }) - expect(() => schema.parse({ ok: true, error: {} })).toThrow() - }) -}) - -describe('wire full-form schemas', () => { - it('parses both carrier forms and the union discriminates on type', () => { - const cq = { type: 'client-request', rpcId: 'r1', method: 'host.describe', payload: {} } - const sr = { type: 'server-response', rpcId: 'r1', result: { ok: true, value: 1 } } - expect(clientRequestSchema.parse(cq).method).toBe('host.describe') - expect(serverResponseSchema.parse(sr).rpcId).toBe('r1') - for (const message of [cq, sr]) expect(rpcMessageSchema.parse(message)).toBeTruthy() - expect(() => rpcMessageSchema.parse({ type: 'other', rpcId: 'x' })).toThrow() - }) - - 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' })).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') - }) -}) - -describe('host domain schemas', () => { - it('validates describe request/value', () => { - expect(hostDescribeRequestSchema.parse({})).toEqual({}) - const value = hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, home: '/h', canOpenPath: true, - }) - expect(value).toMatchObject({ provider: 'p', model: 'm', attachedSessions: 2, canOpenPath: true }) - expect(hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, home: '/h', canOpenPath: false, - }).provider).toBeUndefined() - expect(() => hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, - })).toThrow() - expect(() => hostDescribeValueSchema.parse({ - version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true, - })).toThrow() - }) -}) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts deleted file mode 100644 index aa4137e031..0000000000 --- a/packages/host/apiproxy/tests/session-export.spec.ts +++ /dev/null @@ -1,708 +0,0 @@ -/** - * session.export host path: the GET download endpoint streams a ZIP whose - * files are the stored artifacts verbatim (root + optional descendants), and - * the degenerate compositions fail loudly (missing services → 500, missing - * root → 404, missing descendant → errored stream). - */ - -import { randomBytes } from 'node:crypto' -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import { unzipSync, strFromU8 } from 'fflate' -import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' -import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' -import ApiProxyService, { createApiProxy, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' - -const sid = (id: string): SessionId => id as SessionId - -function header(id: string, parentSession?: SessionId): SessionHeader { - return { - version: 0, - id: sid(id), - createdAt: 1000, - cwd: '/proj', - ...parentSession === undefined ? {} : { parentSession }, - delegationDepth: parentSession === undefined ? 0 : 1, - } -} - -function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { - return { - meta: header(id, parentSession), - filename: 'session.jsonl', - content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, - } -} - -function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { - return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } -} - -/** One durable image object served by the fake attachment store. */ -function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') { - return { - ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef, - data: new Uint8Array([1, 2, 3, 4]), - } -} - -/** A user/message event line carrying one image reference. */ -function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { - return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` -} - -async function buildApi( - artifacts: Record, - descendants: SessionLineageNode[] = [], - services: { - query?: boolean - persistence?: boolean | 'throw' | 'unsupported' - attachments?: boolean | ((ref: ImageAttachmentRef, signal?: AbortSignal) => Promise>) - sessions?: { - get(id: SessionId): { readonly id: SessionId } | undefined - flush(session: { readonly id: SessionId }): Promise - } - readRaw?: (id: SessionId, signal?: AbortSignal) => Promise - traceSession?: (id: SessionId, signal?: AbortSignal) => Promise<{ - target: { header: SessionHeader; live: boolean; persisted: boolean } - ancestors: readonly SessionLineageNode[] - complete: boolean - root: { header: SessionHeader; live: boolean; persisted: boolean } - descendants: readonly SessionLineageNode[] - }> - compressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 - } = {}, -) { - const ctx = new Context() - const query = services.query ?? true - const persistence = services.persistence ?? true - if (query) { - ctx.provide('sessionQuery', { - traceSession: services.traceSession ?? (async () => ({ - target: { header: header('session-root'), live: false, persisted: true }, - ancestors: [], - complete: true, - root: { header: header('session-root'), live: false, persisted: true }, - descendants, - })), - } as never) - } - if (persistence) { - ctx.provide('sessionPersistence', { - supportsRawArtifacts: persistence !== 'unsupported', - readRaw: services.readRaw ?? (async (id: SessionId) => { - if (persistence === 'throw') throw new Error('/host/private/session.jsonl') - return artifacts[id] - }), - } as never) - } - if (services.attachments !== false) { - const readImage = typeof services.attachments === 'function' - ? services.attachments - : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType) - ctx.provide('attachments', { - imageLimits: {} as never, - validateImage: async () => {}, - saveImage: async () => { throw new Error('export never saves images') }, - readImage, - } as never) - } - if (services.sessions !== undefined) ctx.provide('sessions', services.sessions as never) - return createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), - cwd: '/tmp', - ...services.compressionLevel === undefined - ? {} - : { sessionExportCompressionLevel: services.compressionLevel }, - }) -} - -async function responseBytes(response: Response): Promise { - return new Uint8Array(await response.arrayBuffer()) -} - -describe('session export compression config', () => { - it('defaults to level 6 and rejects values outside the integer 0-9 range', () => { - expect(ApiProxyService.Config({})).toEqual({ - sessionExportCompressionLevel: 6, - }) - expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 })) - .toEqual({ sessionExportCompressionLevel: 0 }) - expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 })) - .toEqual({ sessionExportCompressionLevel: 9 }) - for (const value of [-1, 10, 1.5]) { - expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow() - } - }) -}) - -describe('session.export download endpoint', () => { - it('streams a ZIP with the root artifact verbatim under its original filename', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('application/zip') - expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) - }) - - it('preflights root preparation through HEAD without streaming a body', async () => { - const readRaw = vi.fn(async () => artifact('session-root')) - const api = await buildApi({}, [], { readRaw }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), - ) - - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('application/zip') - expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') - expect(response.body).toBeNull() - expect(readRaw).toHaveBeenCalledOnce() - }) - - it('returns a bodyless preparation error from HEAD', async () => { - const api = await buildApi({}) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root', { method: 'HEAD' }), - ) - - expect(response.status).toBe(404) - expect(response.body).toBeNull() - }) - - it('uses the resolved compression level for ZIP entries', async () => { - const root = artifact('session-root', undefined, 'compressible\n'.repeat(32 * 1024)) - const storedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 0 }) - const compressedApi = await buildApi({ 'session-root': root }, [], { compressionLevel: 9 }) - const stored = await storedApi.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const compressed = await compressedApi.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const storedBytes = await responseBytes(stored) - const compressedBytes = await responseBytes(compressed) - expect(compressedBytes.byteLength).toBeLessThan(storedBytes.byteLength) - expect(strFromU8(unzipSync(compressedBytes)['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('includes descendant artifacts under subagents// when requested', async () => { - const api = await buildApi({ - 'session-root': artifact('session-root'), - 'child-a': artifact('child-a', sid('session-root')), - 'grandchild-a': artifact('grandchild-a', sid('child-a')), - }, [ - node('child-a', node('grandchild-a')), - ]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - expect(response.status).toBe(200) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual([ - 'session.jsonl', - 'subagents/child-a/session.jsonl', - 'subagents/grandchild-a/session.jsonl', - ]) - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) - .toBe(artifact('child-a').content) - }) - - it('flushes each live root and descendant immediately before reading its artifact', async () => { - const stored: Record = { - 'session-root': artifact('session-root', undefined, 'stale root'), - 'child-a': artifact('child-a', sid('session-root'), 'stale child'), - } - const durable: Record = { - 'session-root': artifact('session-root', undefined, 'durable root'), - 'child-a': artifact('child-a', sid('session-root'), 'durable child'), - } - const flushed: SessionId[] = [] - const api = await buildApi(stored, [node('child-a')], { - sessions: { - get: id => durable[id] === undefined ? undefined : { id }, - flush: async (session) => { - const artifactAfterFlush = durable[session.id] - if (artifactAfterFlush === undefined) throw new Error('unexpected session') - flushed.push(session.id) - stored[session.id] = artifactAfterFlush - return true - }, - }, - }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - const files = unzipSync(await responseBytes(response)) - expect(flushed).toEqual([sid('session-root'), sid('child-a')]) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('durable root') - expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)).toBe('durable child') - }) - - it('reads a cold artifact without asking the live-session store to flush', async () => { - const flush = vi.fn(async () => true) - const root = artifact('session-root') - const api = await buildApi({ 'session-root': root }, [], { - sessions: { - get: () => undefined, - flush, - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const files = unzipSync(await responseBytes(response)) - expect(flush).not.toHaveBeenCalled() - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('answers 404 for a missing root session', async () => { - const api = await buildApi({}) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(404) - }) - - it('answers 501 when the persistence backend has no per-session raw artifacts', async () => { - const api = await buildApi({}, [], { persistence: 'unsupported' }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(501) - expect(await response.text()).toContain('does not expose per-session raw artifacts') - }) - - it('answers 400 when the sessionId query parameter is absent', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?includeDescendants=true'), - ) - expect(response.status).toBe(400) - }) - - it('answers 400 for an includeDescendants value other than true or false', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), - ) - expect(response.status).toBe(400) - }) - - it('answers 500 when the deployment mounts no persistence or session-query service', async () => { - const api = await buildApi({}, [], { query: false, persistence: false }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - expect(await response.text()).toContain('session-query') - }) - - it('fails the whole export when a descendant has no stored artifact', async () => { - const api = await buildApi({ - 'session-root': artifact('session-root'), - }, [node('child-missing')]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - expect(response.status).toBe(200) - // The stream errors before completing, so the body read rejects rather - // than returning a truncated-but-valid archive. - await expect(response.arrayBuffer()).rejects.toThrow() - }) - - it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { - // The push loop slices by 2^16 code units and must back off one unit when - // the boundary lands inside a surrogate pair; otherwise the pair re-encodes - // as U+FFFD and the exported artifact is silently corrupted. - const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('splits a long artifact on a plain code-unit boundary without backoff', async () => { - // A boundary that lands on a BMP character needs no surrogate backoff; the - // round trip must still be byte-identical across the multi-chunk push. - const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) - }) - - it('waits for response pull capacity before reading the next archive entry', async () => { - const root = artifact('session-root', undefined, [ - imageEventLine('after-root'), - randomBytes(512 * 1024).toString('base64'), - ].join('\n')) - let imageReads = 0 - const api = await buildApi({ 'session-root': root }, [], { - attachments: async (ref) => { - imageReads += 1 - return storedImage(String(ref.attachmentId), ref.mediaType) - }, - }) - vi.useFakeTimers() - let response: Response | undefined - try { - response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - // Exhausting timer turns must not advance a producer whose byte queue is - // full; only a consumer pull can release it. - await vi.runAllTimersAsync() - expect(imageReads).toBe(0) - } finally { - vi.useRealTimers() - } - if (response === undefined) throw new Error('missing export response') - const files = unzipSync(await responseBytes(response)) - expect(imageReads).toBe(1) - expect(files['media/after-root.png']).toEqual(storedImage('after-root').data) - }) - - it('exports an empty artifact as an empty zip entry', async () => { - const root = { ...artifact('session-root'), content: '' } - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files)).toEqual(['session.jsonl']) - expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') - }) - - it('exports a shared lineage node once (seen-set dedup)', async () => { - const api = await buildApi({ - 'session-root': artifact('session-root'), - 'child-a': artifact('child-a', sid('session-root')), - 'child-b': artifact('child-b', sid('session-root')), - shared: artifact('shared', sid('child-a')), - }, [ - node('child-a', node('shared')), - node('child-b', node('shared')), - ]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual([ - 'session.jsonl', - 'subagents/child-a/session.jsonl', - 'subagents/child-b/session.jsonl', - 'subagents/shared/session.jsonl', - ]) - }) - - it('answers 500 without leaking the backend error when the root artifact read fails', async () => { - const api = await buildApi({}, [], { query: true, persistence: 'throw' }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - const body = await response.text() - expect(body).toBe('session log export failed to prepare the stored artifact') - expect(body).not.toContain('/host/private/') - }) - - it('answers the private-error-safe 500 when the live root flush fails', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }, [], { - sessions: { - get: id => ({ id }), - flush: async () => { throw new Error('/host/private/flush-state') }, - }, - }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - const body = await response.text() - expect(body).toBe('session log export failed to prepare the stored artifact') - expect(body).not.toContain('/host/private/') - }) - - it('forwards one request signal through root, lineage, and descendant reads', async () => { - const reads: Array<{ id: SessionId; signal: AbortSignal | undefined }> = [] - const traces: AbortSignal[] = [] - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - reads.push({ id, signal }) - return id === sid('session-root') - ? artifact('session-root') - : artifact('child-a', sid('session-root')) - }, - traceSession: async (_id, signal) => { - if (signal !== undefined) traces.push(signal) - return { - target: { header: header('session-root'), live: false, persisted: true }, - ancestors: [], - complete: true, - root: { header: header('session-root'), live: false, persisted: true }, - descendants: [node('child-a')], - } - }, - }) - const controller = new AbortController() - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - controller.signal, - ) - await response.arrayBuffer() - const producerSignal = traces[0] - if (producerSignal === undefined) throw new Error('missing lineage signal') - expect(reads[0]).toEqual({ id: sid('session-root'), signal: controller.signal }) - expect(reads[1]).toEqual({ id: sid('child-a'), signal: producerSignal }) - const cancellation = new Error('request cancelled after response') - controller.abort(cancellation) - expect(producerSignal.aborted).toBe(true) - expect(producerSignal.reason).toBe(cancellation) - }) - - it('preserves request cancellation instead of translating it to HTTP 500', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }) - const controller = new AbortController() - const cancellation = new Error('request cancelled') - controller.abort(cancellation) - await expect(api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - controller.signal, - )).rejects.toBe(cancellation) - }) - - it('aborts descendant work and terminates ZIP production when its reader cancels', async () => { - let reportDescendantStarted!: (signal: AbortSignal) => void - const descendantStarted = new Promise((resolve) => { - reportDescendantStarted = resolve - }) - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - if (id === sid('session-root')) return artifact('session-root') - if (signal === undefined) throw new Error('missing descendant signal') - reportDescendantStarted(signal) - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(signal.reason as Error) - }, { once: true }) - }) - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - new AbortController().signal, - ) - const reader = response.body?.getReader() - if (reader === undefined) throw new Error('missing response body') - const descendantSignal = await descendantStarted - const cancellation = new Error('download consumer left') - await reader.cancel(cancellation) - expect(descendantSignal.aborted).toBe(true) - expect(descendantSignal.reason).toBe(cancellation) - }) - - it('aborts attachment reads when its reader cancels', async () => { - let reportAttachmentStarted!: (signal: AbortSignal) => void - const attachmentStarted = new Promise((resolve) => { - reportAttachmentStarted = resolve - }) - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('slow-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }, [], { - attachments: async (_ref, signal) => { - if (signal === undefined) throw new Error('missing attachment signal') - reportAttachmentStarted(signal) - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(signal.reason as Error) - }, { once: true }) - }) - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: false }, - new AbortController().signal, - ) - const reader = response.body?.getReader() - if (reader === undefined) throw new Error('missing response body') - const attachmentSignal = await attachmentStarted - const cancellation = new Error('download consumer left during attachment read') - await reader.cancel(cancellation) - expect(attachmentSignal.aborted).toBe(true) - expect(attachmentSignal.reason).toBe(cancellation) - }) - - it('uses a stable Error reason when its reader cancels without one', async () => { - let reportDescendantStarted!: (signal: AbortSignal) => void - const descendantStarted = new Promise((resolve) => { - reportDescendantStarted = resolve - }) - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id, signal) => { - if (id === sid('session-root')) return artifact('session-root') - if (signal === undefined) throw new Error('missing descendant signal') - reportDescendantStarted(signal) - return new Promise((_, reject) => { - signal.addEventListener('abort', () => { - reject(signal.reason as Error) - }, { once: true }) - }) - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - new AbortController().signal, - ) - const reader = response.body?.getReader() - if (reader === undefined) throw new Error('missing response body') - const descendantSignal = await descendantStarted - await reader.cancel() - expect(descendantSignal.reason).toEqual(new Error('session log export stream cancelled')) - }) - - it('normalizes a non-Error descendant failure before erroring the stream', async () => { - const api = await buildApi({}, [node('child-a')], { - readRaw: async (id) => { - if (id === sid('session-root')) return artifact('session-root') - throw 'descendant read failed' - }, - }) - const response = await api.downloads.sessionLog( - { sessionId: sid('session-root'), includeDescendants: true }, - new AbortController().signal, - ) - await expect(response.arrayBuffer()).rejects.toEqual(new Error('descendant read failed')) - }) - - it('includes media objects referenced by the root log under media/.', async () => { - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('img-1'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(200) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) - expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) - }) - - it('collects media referenced from nested tool results', async () => { - const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - nested, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) - }) - - it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { - const block = (id: string, mediaType: string) => - `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` - const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` - const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` - const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - wrapped, - inserted, - chunk, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - const files = unzipSync(await responseBytes(response)) - expect(Object.keys(files).sort()).toEqual([ - 'media/chunk-1.png', - 'media/inserted-1.gif', - 'media/wrapped-1.jpg', - 'session.jsonl', - ]) - }) - - it('deduplicates one media object referenced by several included logs', async () => { - const line = imageEventLine('shared-img') - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - line, - ].join('\n') + '\n') - const child = artifact('child-a', sid('session-root'), [ - '{"type":"session","version":0,"id":"child-a","createdAt":1000}', - line, - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - const files = unzipSync(await responseBytes(response)) - expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data) - expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png']) - }) - - it('includes descendant media only when descendants are requested', async () => { - const child = artifact('child-a', sid('session-root'), [ - '{"type":"session","version":0,"id":"child-a","createdAt":1000}', - imageEventLine('child-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) - const without = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) - const withDescendants = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), - ) - expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ - 'media/child-img.png', - 'session.jsonl', - 'subagents/child-a/session.jsonl', - ]) - }) - - it('fails the whole export when a referenced image cannot be read', async () => { - const root = artifact('session-root', undefined, [ - '{"type":"session","version":0,"id":"session-root","createdAt":1000}', - imageEventLine('gone-img'), - ].join('\n') + '\n') - const api = await buildApi({ 'session-root': root }, [], { - attachments: async () => { throw new Error('attachment bytes missing') }, - }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(200) - await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing') - }) - - it('answers 500 when the deployment mounts no attachments service', async () => { - const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) - const response = await toFetchHandler(api).fetch( - new Request('http://host/api/session.export?sessionId=session-root'), - ) - expect(response.status).toBe(500) - expect(await response.text()).toContain('attachments') - }) -}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json deleted file mode 100644 index 048fffcc72..0000000000 --- a/packages/host/apiproxy/tsconfig.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../credentials/credentials" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../api/session-controller/tsconfig.host.json" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../attachment/attachment" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/agent-default-model" - }, - { - "path": "../../core/session" - }, - { - "path": "../../session/session-persistence" - }, - { - "path": "../../session-query/session-query" - }, - { - "path": "../../runtime-diagnostics/invariants" - }, - { - "path": "../../util/native-command" - }, - { - "path": "../../util/crypto" - } - ] -} diff --git a/packages/typert/generator/tests/cordis-catalog.spec.ts b/packages/typert/generator/tests/cordis-catalog.spec.ts index ab62291aed..240aeab248 100644 --- a/packages/typert/generator/tests/cordis-catalog.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog.spec.ts @@ -90,9 +90,6 @@ describe('Typert-backed Cordis catalog', () => { // An interface-typed key is described by its Service Definition: that is where // the contract and, by repository convention, the member JSDoc live. expect(byKey.get('lsp')?.type).toBe('LspService') - // The Service Definition may sit anywhere in the package, including a nested - // contract directory (`src/api/`), while the Context merge stays in `src`. - expect(byKey.get('apiProxy')?.type).toBe('ApiProxy') // Two packages describe `ctx.typert` — a merge-extensible interface in // type-meta and the implementing class in registry. The class wins: it is the // object a caller meets and it carries the documentation. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a021582cd9..06d5d75a10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -376,9 +376,6 @@ importers: '@deepseek-ai/dsh-fs-sandbox': specifier: workspace:^ version: link:../../packages/fs/fs-sandbox - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../packages/host/apiproxy '@deepseek-ai/dsh-host-frontend-static': specifier: workspace:^ version: link:../../packages/host/frontend-static @@ -1575,9 +1572,6 @@ importers: '@deepseek-ai/dsh-file-reference-local': specifier: workspace:^ version: link:../../context/file-reference-local - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-host-directory-picker-auto': specifier: workspace:^ version: link:../../host/directory-picker-auto @@ -1654,6 +1648,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:^ @@ -1661,15 +1658,15 @@ importers: '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../../host/directory-picker @@ -2274,12 +2271,18 @@ 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 '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale + '@deepseek-ai/dsh-client-store': + specifier: workspace:^ + version: link:../store '@deepseek-ai/dsh-client-test-runtime': specifier: workspace:^ version: link:../../test-support/client-runtime @@ -4974,12 +4977,12 @@ importers: '@deepseek-ai/dsh-bash-sandbox': specifier: workspace:^ version: link:../../shell/bash-sandbox + '@deepseek-ai/dsh-client-connection': + specifier: workspace:^ + version: link:../../client/connection '@deepseek-ai/dsh-client-modules': specifier: workspace:^ version: link:../../client/modules - '@deepseek-ai/dsh-host-apiproxy': - specifier: workspace:^ - version: link:../../host/apiproxy '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -5799,67 +5802,6 @@ importers: specifier: workspace:^ version: link:../../core/tools - packages/host/apiproxy: - dependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-default-model': - specifier: workspace:^ - version: link:../../core/agent-default-model - '@deepseek-ai/dsh-api-session-controller': - specifier: workspace:^ - version: link:../../api/session-controller - '@deepseek-ai/dsh-attachment': - specifier: workspace:^ - version: link:../../attachment/attachment - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-native-command': - specifier: workspace:^ - version: link:../../util/native-command - '@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-query': - specifier: workspace:^ - version: link:../../session-query/session-query - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:^ - version: link:../../util/crypto - '@deepseek-ai/schemastery': - specifier: link:../../../vendor/schemastery - version: link:../../../vendor/schemastery - fflate: - specifier: ^0.8.2 - version: 0.8.3 - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:^ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-credentials': - specifier: workspace:^ - version: link:../../credentials/credentials - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../runtime-diagnostics/invariants - '@deepseek-ai/dsh-settings': - specifier: workspace:^ - version: link:../../settings/settings - '@deepseek-ai/dsh-typert-protocol': - specifier: workspace:^ - version: link:../../typert/protocol - '@deepseek-ai/dsh-typert-registry': - specifier: workspace:^ - version: link:../../typert/registry - packages/host/directory-picker: devDependencies: '@deepseek-ai/cordis': diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index f464732b40..e76c248aa7 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -198,7 +198,7 @@ export function expectedDshPackageFiles(manifest: PackageManifest): readonly str ...exportDefault(manifest, './worker') === './lib/worker.js' ? ['lib/worker.js'] : [], // UI plugin packages ship their browser bundle beside the node lib // (single-artifact ruling: dist/ retired, ./client resolves lib/client.js). - // Keyed on the artifact path, not the subpath name: apiproxy's ./client is + // Keyed on the artifact path, not the subpath name: a package's ./client is // a browser-safe source channel, not a bundle. ...exportDefault(manifest, './client') === './lib/client.js' ? ['lib/client.js'] : [], // runtime's shell-held loader subpath ships as its own bundle beside the client half. diff --git a/scripts/doc-typecheck-paths.ts b/scripts/doc-typecheck-paths.ts index ec17f1b29c..1a0ae8ecd9 100644 --- a/scripts/doc-typecheck-paths.ts +++ b/scripts/doc-typecheck-paths.ts @@ -1,7 +1,7 @@ /** Map one workspace source alias target to its declaration-build target. */ export function builtDeclarationPath(candidate: string): string { // Two workspace path forms exist: whole-package entries end in /src, subpath - // wildcards (apiproxy's browser-safe /api and /client channels) in /src/*. + // wildcards (browser-safe /types and /client channels) in /src/*. if (candidate.endsWith('/src')) { return `${candidate.slice(0, -'/src'.length)}/lib/types` } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 5c2fcca659..dcca3d0722 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -58,7 +58,6 @@ export const SERVICE_PAGE: Record = { agentDefaultModel: 'core.md', agentPresets: 'core.md', agents: 'core.md', - apiProxy: 'typert.md', approval: 'approval.md', attachments: 'attachment.md', shell: 'shell.md', @@ -618,6 +617,7 @@ export const LINK_MAP: Readonly> = { DirectoryListing: 'workspace.md', TypertContribution: 'invariants.md', TypertRemoteEventSource: 'typert.md', + RemoteEventHostInfo: 'typert.md', TypertFace: 'invariants.md', TypertPackageFilter: 'invariants.md', TypertPackageRecord: 'invariants.md', @@ -661,7 +661,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { BashEnvVariableInfo: 'service-local metadata type is owned by packages/shell/tool-bash/src/index.ts', CompactionAgentContext: 'compaction service input is owned by packages/compaction/compaction/src/index.ts', ManualCompactAgentContext: 'manual compaction service input is owned by packages/compaction/compaction/src/index.ts', - ClientResponse: 'wire response message is owned by packages/host/apiproxy/src/api/rpc.ts', + ClientResponse: 'wire response message is owned by packages/client/connection/src/rpc.ts', ApprovalRequestId: 'dynamic Plugin approval identity is owned by packages/extensions/cordis-host-runner/src/types.ts', CordisErrorDetails: 'Cordis runtime error payload is owned by packages/extensions/cordis-host-runner/src/types.ts', CordisInspectPlatform: 'Cordis inspect platform identity is owned by packages/extensions/cordis-host-runner/src/types.ts', @@ -714,7 +714,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly> = { PermissionSelect: 'permissions projection payload is owned by packages/interaction/permission-presets/src/types.ts', PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md', RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts', - RpcReceipt: 'carrier-layer receipt is owned by packages/host/apiproxy/src/api/rpc.ts', + RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts', Sandbox: 'external E2B SDK handle is owned by packages/e2b/e2b/README.md', SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts', SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 1119f7be56..65876fd5d0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -104,7 +104,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable binary attachment storage', mode: 'seam', implementations: ['attachment-local'], - consumers: ['api-session-controller', 'host-apiproxy', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], + consumers: ['api-session-controller', 'tool-fs', 'llm-pi-ai', 'llm-deepseek'], note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', }, { @@ -236,8 +236,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'User-settings seam', mode: 'seam', implementations: ['settings-file'], - consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'], - note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer.', + consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'], + note: 'Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer.', }, { key: 'subagentModelSelection', @@ -253,8 +253,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Credential seam', mode: 'seam', implementations: ['credentials-local'], - consumers: ['llm-deepseek', 'llm-pi-ai', 'host-apiproxy'], - note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage.', + consumers: ['api-settings-controller', 'llm-deepseek', 'llm-pi-ai'], + note: 'Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage.', }, { key: 'authorization', @@ -389,15 +389,15 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session-projection', title: 'Session projection units', mode: 'core', - consumers: ['tool-todo', 'session-title', 'host-apiproxy'], - note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values.', + consumers: ['api-session-controller', 'tool-todo', 'session-title'], + note: 'Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values.', }, { key: 'sessionProjectionCache', pkg: 'session-projection-cache', title: 'Persisted projection cache', mode: 'core', - consumers: ['host-apiproxy'], + consumers: ['api-session-controller', 'session-query', 'session-reference', 'subagent'], note: 'Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs.', }, { @@ -422,7 +422,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent-default-model', title: 'Default Agent model selection', mode: 'core', - consumers: ['headless', 'host-apiproxy'], + consumers: ['api-session-controller', 'headless'], note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner.', }, { @@ -648,14 +648,6 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['tool-lsp'], note: 'Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result.', }, - { - key: 'apiProxy', - pkg: 'host-apiproxy', - title: 'Host API dispatch', - mode: 'core', - consumers: ['client-connection'], - note: 'The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb.', - }, { key: 'dynamicCordisRunner', pkg: 'cordis-host-runner', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 4ea84b36fe..46e80a86c1 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -114,7 +114,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/e2b/fs-e2b': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, - 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register nothing model-facing.' }, 'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers nothing model-facing.' }, 'packages/host/directory-picker-auto': { kind: 'none', reason: 'The GUI-host picking chooser only mounts a backend row; it registers nothing model-facing.' }, 'packages/host/directory-picker-browse': { kind: 'none', reason: 'The GUI-host picking backend registers nothing model-facing.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 1c06baeac9..661a546bf8 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -136,7 +136,12 @@ "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], - "@deepseek-ai/dsh-host-apiproxy": ["./packages/host/apiproxy/src"], + // One wildcard maps every @deepseek-ai/dsh- to its source. Package + // dir names are unique across groups, so first-on-disk-wins resolution is + // unambiguous; adding a package under an existing group needs no edit + // here. The aggregates' project references (tsconfig.host.json / + // tsconfig.client.json) stay explicit — TS project references have no + // wildcard form. "@deepseek-ai/dsh-host-directory-picker": ["./packages/host/directory-picker/src"], "@deepseek-ai/dsh-host-directory-picker/*": ["./packages/host/directory-picker/src/*"], "@deepseek-ai/dsh-host-directory-picker-browse": ["./packages/host/directory-picker-browse/src"], @@ -145,8 +150,6 @@ "@deepseek-ai/dsh-host-directory-picker-native/*": ["./packages/host/directory-picker-native/src/*"], "@deepseek-ai/dsh-host-directory-picker-auto": ["./packages/host/directory-picker-auto/src"], "@deepseek-ai/dsh-host-directory-picker-auto/*": ["./packages/host/directory-picker-auto/src/*"], - "@deepseek-ai/dsh-host-apiproxy/client": ["./packages/host/apiproxy/src/fetch/client.ts"], - "@deepseek-ai/dsh-host-apiproxy/*": ["./packages/host/apiproxy/src/*"], "@deepseek-ai/dsh-host-webserver": ["./packages/host/webserver/src"], "@deepseek-ai/dsh-host-frontend-static": ["./packages/host/frontend-static/src"], "@deepseek-ai/dsh-host-plugin-inventory": ["./packages/host/plugin-inventory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 8b70f8d2c7..115c328537 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -2,7 +2,7 @@ // Client-side typecheck aggregate: packages/client tests (.ts and .tsx). // Split from the host aggregate because both sides merge cordis Context // under the same keys (sessions, loader) with different services; shared - // leaves (session/llm/tools/apiproxy/...) build once and are referenced by + // leaves (session/llm/tools/...) build once and are referenced by // both programs through each client package's own references. "extends": "./tsconfig.base.client.json", "compilerOptions": { diff --git a/tsconfig.host.json b/tsconfig.host.json index a707fa8df7..14bfe0822f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -313,7 +313,6 @@ { "path": "./packages/hooks/hooks-claude-code" }, { "path": "./packages/hooks/hooks-codex" }, { "path": "./packages/mcp/mcp-client" }, - { "path": "./packages/host/apiproxy" }, { "path": "./packages/host/directory-picker" }, { "path": "./packages/host/directory-picker-auto" }, { "path": "./packages/host/directory-picker-browse" }, diff --git a/vitest.config.ts b/vitest.config.ts index 2127545939..ecfd00ded4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -330,9 +330,6 @@ export default defineConfig({ // by decision: its correctness signal is its uninstrumented suite and // the packer's end-to-end image spec. 'packages/experimental/webworker-runtime/src/**/*.ts', - 'packages/host/apiproxy/src/index.ts', - 'packages/host/apiproxy/src/invariant.ts', - 'packages/host/apiproxy/src/api-proxy.ts', // Projection/command round: executor lifecycle branches and the // registry's drive tails need the same maturing lanes. TODO(gui): // cover and remove with the client test lane above. From e57e7c3f25c4d2386e74600ac6fe0faa14ea0d8a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:58:42 +0800 Subject: [PATCH 114/130] docs(api): describe Connection-owned transport --- ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 +++ ...026-07-19-gui-layering-and-rpc-protocol.md | 1 + ...-07-19-gui-layering-and-rpc-protocol.zh.md | 1 + ...08-04-websocket-downlink-carrier.i18n.yaml | 6 +++ .../2026-08-04-websocket-downlink-carrier.md | 1 + ...026-08-04-websocket-downlink-carrier.zh.md | 1 + .agents/notes/archived/manifest.json | 6 +++ ...19-gui-layering-and-rpc-protocol.i18n.yaml | 6 --- ...7-19-gui-web-client-architecture.i18n.yaml | 4 +- .../2026-07-19-gui-web-client-architecture.md | 10 ++-- ...26-07-19-gui-web-client-architecture.zh.md | 10 ++-- ...7-23-client-plugin-loading-model.i18n.yaml | 4 +- .../2026-07-23-client-plugin-loading-model.md | 2 +- ...26-07-23-client-plugin-loading-model.zh.md | 2 +- ...tree-boot-and-transport-layering.i18n.yaml | 4 +- ...config-tree-boot-and-transport-layering.md | 6 +-- ...fig-tree-boot-and-transport-layering.zh.md | 6 +-- ...07-28-api-browser-trust-boundary.i18n.yaml | 4 +- .../2026-07-28-api-browser-trust-boundary.md | 2 +- ...026-07-28-api-browser-trust-boundary.zh.md | 2 +- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 8 +-- ...-28-directory-picker-capability-seam.zh.md | 8 +-- ...-token-usage-and-request-context.i18n.yaml | 4 +- ...ojected-token-usage-and-request-context.md | 4 +- ...cted-token-usage-and-request-context.zh.md | 4 +- ...-08-03-per-session-agent-presets.i18n.yaml | 4 +- .../2026-08-03-per-session-agent-presets.md | 6 +-- ...2026-08-03-per-session-agent-presets.zh.md | 6 +-- ...08-04-websocket-downlink-carrier.i18n.yaml | 6 --- ...headless-direct-core-entry-point.i18n.yaml | 4 +- ...-08-09-headless-direct-core-entry-point.md | 16 +++--- ...-09-headless-direct-core-entry-point.zh.md | 16 +++--- ...2026-08-10-remote-event-delivery.i18n.yaml | 4 +- .../2026-08-10-remote-event-delivery.md | 11 ++-- .../2026-08-10-remote-event-delivery.zh.md | 11 ++-- ...-unary-apiproxy-remote-migration.i18n.yaml | 4 +- ...6-08-10-unary-apiproxy-remote-migration.md | 18 ++++--- ...8-10-unary-apiproxy-remote-migration.zh.md | 18 ++++--- ...sion-history-and-event-transport.i18n.yaml | 4 +- ...-18-session-history-and-event-transport.md | 8 +-- ...-session-history-and-event-transport.zh.md | 8 +-- ...-24-browser-token-authentication.i18n.yaml | 4 +- ...2026-08-24-browser-token-authentication.md | 2 +- ...6-08-24-browser-token-authentication.zh.md | 2 +- ...-bounded-cold-blank-verification.i18n.yaml | 4 +- ...6-08-13-bounded-cold-blank-verification.md | 2 +- ...8-13-bounded-cold-blank-verification.zh.md | 2 +- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 2 +- ...-image-input-and-durable-attachments.zh.md | 2 +- .../2026-07-27-web-session-search.i18n.yaml | 4 +- .../feature/2026-07-27-web-session-search.md | 2 +- .../2026-07-27-web-session-search.zh.md | 2 +- ...07-27-web-subagent-conversations.i18n.yaml | 4 +- .../2026-07-27-web-subagent-conversations.md | 4 +- ...026-07-27-web-subagent-conversations.zh.md | 4 +- ...28-todo-plan-clears-on-next-turn.i18n.yaml | 4 +- ...026-07-28-todo-plan-clears-on-next-turn.md | 2 +- ...-07-28-todo-plan-clears-on-next-turn.zh.md | 2 +- ...-07-28-tool-call-file-open-in-os.i18n.yaml | 4 +- .../2026-07-28-tool-call-file-open-in-os.md | 2 +- ...2026-07-28-tool-call-file-open-in-os.zh.md | 2 +- ...mission-default-for-new-sessions.i18n.yaml | 4 +- ...-31-permission-default-for-new-sessions.md | 2 +- ...-permission-default-for-new-sessions.zh.md | 2 +- ...default-model-follows-the-picker.i18n.yaml | 4 +- ...-08-07-default-model-follows-the-picker.md | 4 +- ...-07-default-model-follows-the-picker.zh.md | 4 +- ...026-08-10-web-session-log-export.i18n.yaml | 4 +- .../2026-08-10-web-session-log-export.md | 4 +- .../2026-08-10-web-session-log-export.zh.md | 4 +- ...fig-solution-root-two-aggregates.i18n.yaml | 4 +- ...2-tsconfig-solution-root-two-aggregates.md | 2 +- ...sconfig-solution-root-two-aggregates.zh.md | 2 +- ...remotes-generated-contract-build.i18n.yaml | 4 +- ...08-api-remotes-generated-contract-build.md | 2 +- ...api-remotes-generated-contract-build.zh.md | 2 +- ...08-08-copy-only-preset-authoring.i18n.yaml | 4 +- .../2026-08-08-copy-only-preset-authoring.md | 2 +- ...026-08-08-copy-only-preset-authoring.zh.md | 2 +- ...ency-swaps-rejected-by-nih-audit.i18n.yaml | 4 +- ...-dependency-swaps-rejected-by-nih-audit.md | 2 +- ...pendency-swaps-rejected-by-nih-audit.zh.md | 2 +- AGENTS.md | 2 +- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/web/tests/README.i18n.yaml | 4 +- apps/web/tests/README.md | 4 +- apps/web/tests/README.zh.md | 2 +- apps/web/tests/replay-round-trip.e2e.ts | 2 +- docs/api-gateway.i18n.yaml | 4 +- docs/api-gateway.md | 8 +-- docs/api-gateway.zh.md | 8 +-- docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 33 ++++++------ docs/capability-seams.zh.md | 33 ++++++------ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 54 ++++++++----------- docs/config-catalog.zh.md | 52 ++++++++---------- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 27 +++++----- docs/module-graph.zh.md | 27 +++++----- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 6 +++ docs/subsystems/session.zh.md | 6 +++ docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 6 +++ docs/subsystems/settings.zh.md | 6 +++ docs/subsystems/typert.i18n.yaml | 4 +- docs/subsystems/typert.md | 17 +++--- docs/subsystems/typert.zh.md | 17 +++--- docs/subsystems/web-client.i18n.yaml | 4 +- docs/subsystems/web-client.md | 4 +- docs/subsystems/web-client.zh.md | 4 +- docs/subsystems/web-server.i18n.yaml | 4 +- docs/subsystems/web-server.md | 2 +- docs/subsystems/web-server.zh.md | 2 +- packages/AGENTS.md | 2 +- packages/api/README.i18n.yaml | 4 +- packages/api/README.md | 5 +- packages/api/README.zh.md | 5 +- packages/api/gateway/README.i18n.yaml | 4 +- packages/api/gateway/README.md | 6 +-- packages/api/gateway/README.zh.md | 6 +-- packages/api/remotes/README.i18n.yaml | 4 +- packages/api/remotes/README.md | 6 +-- packages/api/remotes/README.zh.md | 6 +-- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 3 +- packages/api/session-controller/README.zh.md | 3 +- .../api/settings-controller/README.i18n.yaml | 4 +- packages/api/settings-controller/README.md | 2 +- packages/api/settings-controller/README.zh.md | 2 +- packages/client/AGENTS.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 10 ++-- packages/client/connection/README.zh.md | 10 ++-- .../client/ui-agent-preset/README.i18n.yaml | 4 +- packages/client/ui-agent-preset/README.md | 2 +- packages/client/ui-agent-preset/README.zh.md | 2 +- .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 4 +- packages/client/ui-deliverables/README.zh.md | 4 +- packages/host/README.i18n.yaml | 4 +- packages/host/README.md | 11 ++-- packages/host/README.zh.md | 11 ++-- packages/host/webserver/README.i18n.yaml | 4 +- packages/host/webserver/README.md | 2 +- packages/host/webserver/README.zh.md | 2 +- 154 files changed, 464 insertions(+), 444 deletions(-) create mode 100644 .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-07-19-gui-layering-and-rpc-protocol.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md (99%) create mode 100644 .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml rename .agents/notes/{implemented => archived}/architecture/2026-08-04-websocket-downlink-carrier.md (99%) rename .agents/notes/{implemented => archived}/architecture/2026-08-04-websocket-downlink-carrier.zh.md (99%) delete mode 100644 .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml diff --git a/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml new file mode 100644 index 0000000000..cce84b1ef3 --- /dev/null +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.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/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +2026-07-19-gui-layering-and-rpc-protocol.md: b27d8d024612d890819bfca9b43c0c81464dfdd3 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 3cf4ba6421c7332c1f8cebb61656a1546f3ad45f diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md rename to .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 372bf49260..b27d8d0246 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -1,6 +1,7 @@ # Agent Note: GUI layering and the RPC protocol — host/client layering by capability provider, the four-quadrant message model, and the fetch carrier Status: implemented +Archived: 2026-08-27 English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md rename to .agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index ecce57c01c..3cf4ba6421 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -1,6 +1,7 @@ # Agent Note: GUI 分层与 RPC 协议——host/client 按能力提供方分层、四象限消息模型与 fetch 载体 Status: implemented +Archived: 2026-08-27 [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 diff --git a/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml new file mode 100644 index 0000000000..aad9bee1cc --- /dev/null +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.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/archived/architecture/2026-08-04-websocket-downlink-carrier.md +2026-08-04-websocket-downlink-carrier.md: 5edcdd95cf2845d455a61930a9fc00e7e57e72eb +2026-08-04-websocket-downlink-carrier.zh.md: 213697effb8655583e7c420e58acfd171261a8d8 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md rename to .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md index 420a0d30f3..5edcdd95cf 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.md @@ -1,6 +1,7 @@ # Agent Note: WebSocket carrier for browser downlinks Status: implemented +Archived: 2026-08-27 English | [中文](2026-08-04-websocket-downlink-carrier.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md rename to .agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md index 5f277a4ed3..213697effb 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.zh.md +++ b/.agents/notes/archived/architecture/2026-08-04-websocket-downlink-carrier.zh.md @@ -1,6 +1,7 @@ # Agent Note: 浏览器下行 WebSocket 载体 Status: implemented +Archived: 2026-08-27 [English](2026-08-04-websocket-downlink-carrier.md) | 中文 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 479a150d56..a770e0ac92 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -25,6 +25,9 @@ "architecture/2026-07-05-windows-fs-permissions.i18n.yaml": "sha256:7e61ee9bbd9de4bf3285a6f250d9625bd062e5fb90279dbffd64c820f1f7fe6b", "architecture/2026-07-05-windows-fs-permissions.md": "sha256:03734da511eae3b0736f7cad73d9da76ae2f69f9d5ed09089b0121ccb135a861", "architecture/2026-07-05-windows-fs-permissions.zh.md": "sha256:454848057ea905fe76c88d17264e71e71fb685f08f82088de6976878372865c3", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml": "sha256:855477999c84236430dc9308e16797eb21658a67a72b8537315c6964ff0c0c0a", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.md": "sha256:3517f37e98e74865dced37d5e1559d443e8fa827031c8335e99a1e910586e9ac", + "architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md": "sha256:8181386d957fa6d6b3eb9b05d29adb10804b5a926853425415d368cc7fceaefa", "architecture/2026-07-22-tui-interactive-extension-service.i18n.yaml": "sha256:1b4822af5c8d642b73e3a0b04fb0a1dea9f50d0147046fbef53f5e49c030fb91", "architecture/2026-07-22-tui-interactive-extension-service.md": "sha256:ca6b2774f4821e66f7c8397f20fcd34926728ded853fa48cbe451db7a8d2f883", "architecture/2026-07-22-tui-interactive-extension-service.zh.md": "sha256:5b060c7626ee796c27108be7467a5e4be0677d7525d383336e7ec31ddce5c303", @@ -43,6 +46,9 @@ "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", + "architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml": "sha256:b9d742d068a0e36df2f3030f6a04a3638f3461f7e10b50ed0cd6bd5e85de5019", + "architecture/2026-08-04-websocket-downlink-carrier.md": "sha256:b9be27a4cda8abd410c6e8b728c571f96b4891eb003c5200e9a0ffbbc9145b42", + "architecture/2026-08-04-websocket-downlink-carrier.zh.md": "sha256:118b71b33710a7a3d28375c48b42c1ec19993ca7e13f286dd6ea64f934456f46", "architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc", "architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf", "architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa", diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml deleted file mode 100644 index 38e07804b7..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 372bf4926011835999ebae9b1e2d1f5beb8eb663 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: ecce57c01c155c2a0b19b7729da13c39d1a520a6 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index b80381f031..e48351a2ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 -2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 +2026-07-19-gui-web-client-architecture.md: 703bf2b873eee8afc7e13f89acba99b06fc98745 +2026-07-19-gui-web-client-architecture.zh.md: d61cc8119929b3efc912221df3343918e4851308 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 4448fd5c68..703bf2b873 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-19-gui-web-client-architecture.zh.md) -> Division of labor: the channel-independent layering model and RPC protocol (message model / type system / contract face / client base class) are in the [layering and RPC protocol note](2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. +> Division of labor: the historical channel-independent layering model and RPC protocol are recorded in the [archived layering and RPC protocol note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); this document = the browser side: how the client cordis tree loads, how UI plugins compose through slots and services, and how the React-free object layer feeds React through immutable snapshots. ## Problem @@ -17,7 +17,7 @@ Both ends run cordis. The host is a cordis plugin tree; the browser runs a secon ``` ┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ │ sessions/agents/SessionLog │ │ client cordis root ctx │ -│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ +│ Connection + Gateway: RPC/events│◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ │ webserver: │ │ ├ immediately entries: connection/runtime/ │ │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ @@ -42,7 +42,7 @@ Implementation homes: registry core and the props-share types live in `packages/ ## Services and scope addressing -A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (api client + stream handles), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). +A service is a plugin's only API toward other plugins (UI components and injection faces are not APIs; a plugin nobody calls mounts no service — ui-trajectory is the minimal-plugin exemplar: no ctx service, only view-slot registrations). The roster: `ctx.connection` (RPC transport + generation state), `ctx.slots` (registry wrapper emitting `slots/changed`, render entry, renderer installation contract), `ctx.sessions` (list store, current-session state, scope tree), `ctx.loader`, `ctx.theme`, `ctx.i18n`, `ctx.layout` (cross-plugin view navigation), `ctx.conversation` (send/cancel/startSession). Viewing state that used to live in service stores (panel widths, selection, drafts) now lives in entry-declared stores per the [slot system standard](2026-07-22-slot-type-chain-implementation.md). There is no component registration model besides slots — the former view and tool rings both dissolved into it. Conversation views are entries of the `'conversation.view'` list slot ui-conversation declares, tab metadata rides the registration options (`id`/`order`/`label`), and per-view chrome lives inside the view components themselves. Final Chat business Nodes dispatch through the keyed/session `'conversation.chat.node'` slot; ui-tool owns its `tool-call` entry, recursively renders the supplied `subCalls`, and declares the keyed/session `'tool.call.toolview'` child slot. The key space stays runtime-open (SlotMap declares slots, never keys), and roots and descendants dispatch by `entryKey: toolName` with `GenericToolCard` as the fallback. Business packages register atomic views through `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))`; the declaration is the load and reload dependency ([decision](2026-08-05-slot-declaration-injection.md)). ui-conversation separately delegates the selected call's details body through `'conversation.details.tool'`, so ui-tool's card models remain the single presentation owner without making conversation import Tool components. The target-neutral event and view registries are data assembly seams rather than parallel component registries ([decision](2026-08-09-client-conversation-node-assembly.md)). @@ -53,7 +53,7 @@ There is no component registration model besides slots — the former view and t Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): ``` -mux/host frames (ConnectionController pump, injected sinks) +$events frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. -- **ConnectionController** (in `packages/client/connection`): opens the mux/host streams, pumps with for-await, reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer faces only `IApiClient`; Web carriage uses HTTP POST for the two client→server quadrants and [one WebSocket per logical stream](2026-08-04-websocket-downlink-carrier.md) for the two server→client quadrants, while the client class family remains the layering note's territory. +- **ConnectionController** (in `packages/client/connection`): opens the `$events` Remote stream, pumps with for-await, and reconnects with exponential backoff (500ms doubling to 10s, jitter, unlimited) behind a generation fence; sinks are injected one-way (the Controller does not know Session). Reconnect = rebuild: `onConnected` → list refresh + per-open-session resync. The object layer calls generated namespaces through `ctx.remote`; Web carriage uses HTTP POST for unary Remote calls and API Gateway's WebSocket mux for logical streams, while Connection owns request transport and generations. ## The React face (`packages/client/ui-renderer`) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index e8a8121a1a..d61cc81199 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-web-client-architecture.md) | 中文 -> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 +> 分工线:历史上的通道无关分层模型与 RPC 协议见[已归档的分层与 RPC 协议笔记](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 ## Problem @@ -17,7 +17,7 @@ Status: implemented ``` ┌─ Host ─────────────────────────┐ ┌─ Browser ─────────────────────────────────────────┐ │ sessions/agents/SessionLog │ │ client cordis root ctx │ -│ apiproxy: RPC + mux/host 双流 │◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ +│ Connection + Gateway: RPC/events│◀─▶│ ├ vendored Loader + ctx.modules(内核,壳静态持有)│ │ webserver: │ │ ├ immediately entries: connection/runtime/ │ │ ├ GET /plugins//client.js │ │ │ ui-theme/i18n(fetch bundle,boot 预拉) │ │ └ GET / 注入 __DSH_BOOT__ 图 │ │ ├ lazy entries: layout/sidebar/ │ @@ -42,7 +42,7 @@ slot 体系有自己的笔记——[slot 体系标准](2026-07-22-slot-type-chai ## 服务与 scope 寻址 -服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(api client + 流句柄)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 +服务是插件对其他插件的唯一 API(UI 组件与注入面都不是 API;无人调用的插件不挂服务——ui-trajectory 即最小插件样板:无 ctx 服务,只做视图 slot 注册)。名册:`ctx.connection`(RPC 传输 + generation 状态)、`ctx.slots`(注册表包装层,发 `slots/changed`,渲染入口,渲染器安装约定)、`ctx.sessions`(列表 store、当前会话状态、scope 树)、`ctx.loader`、`ctx.theme`、`ctx.i18n`、`ctx.layout`(跨插件视图导航)、`ctx.conversation`(send/cancel/startSession)。过去住在服务 store 里的观看态(面板宽、选中、草稿)现按 [slot 体系标准](2026-07-22-slot-type-chain-implementation.zh.md) 住 entry 声明的 store。 slot 之外不存在第二种组件注册模型——原视图环与工具环都已溶解进来。会话视图即 ui-conversation 声明的 `'conversation.view'` list slot entry,tab 元数据随注册 options(`id`/`order`/`label`)走,per-view chrome 住视图组件自身。最终 Chat 业务 Node 通过 keyed/session `'conversation.chat.node'` slot 分发;ui-tool 拥有其中的 `tool-call` entry,递归渲染传入的 `subCalls`,并声明 keyed/session `'tool.call.toolview'` 子 slot。key 空间仍在运行时开放(SlotMap 声明 slot、从不声明 key),root 与任意深度的后代都按 `entryKey: toolName` 分发,以 `GenericToolCard` 兜底。业务包通过 `ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({ name: 'tool.call.toolview', key: '' }, Row))` 注册原子视图;声明本身就是加载与重载依赖([决策](2026-08-05-slot-declaration-injection.zh.md))。ui-conversation 还通过 `'conversation.details.tool'` 委托 selected call 的详情正文,使 ui-tool 的 card model 保持为唯一展示所有者,同时避免 conversation 导入 Tool 组件。与 target 无关的事件注册表和视图注册表是数据组装 seam,不是平行组件注册表([决策](2026-08-09-client-conversation-node-assembly.zh.md))。 @@ -53,7 +53,7 @@ slot 之外不存在第二种组件注册模型——原视图环与工具环都 帧从这里进、快照从这里出、Conversation assembler 坐在中间——React-free(零 React import,grep 可断言): ``` -mux/host frames (ConnectionController pump, injected sinks) +$events frames (ConnectionController pump, injected sinks) │ ▼ SessionManager.handleMuxEnvelope / handleHostEnvelope @@ -73,7 +73,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](2026-08-08-client-tool-presentation-ownership.zh.md)拥有 Tool 递归渲染。 -- **ConnectionController**(在 `packages/client/connection`):开 mux/host 双流、for-await 泵入,代际围栏之内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sinks 单向注入(Controller 不认识 Session)。重连 = 重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层只面向 `IApiClient`;Web 承载以 HTTP POST 载两个 client→server 象限、以[每逻辑流一条 WebSocket](2026-08-04-websocket-downlink-carrier.zh.md)载两个 server→client 象限,客户端类族归分层笔记属地。 +- **ConnectionController**(位于 `packages/client/connection`):打开 `$events` Remote 流、通过 for-await 泵入,并在 generation 围栏内指数退避重连(500ms 翻倍至 10s 封顶、抖动、无限重试);sink 单向注入,Controller 不认识 Session。重连即重建:`onConnected` → 列表刷新 + 各已打开会话 resync。对象层通过 `ctx.remote` 调用生成的命名空间;Web 载体以 HTTP POST 承载 Remote 一元调用,以 API Gateway 的 WebSocket mux 承载逻辑流,Connection 则拥有请求传输与 generation。 ## React 面(`packages/client/ui-renderer`) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 445a1264c8..82efeeb49e 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md -2026-07-23-client-plugin-loading-model.md: 1d08aa71378e7e135b67428a1afca2499de7cc60 -2026-07-23-client-plugin-loading-model.zh.md: 8aaa5d38f63d115fa89216d2b37682ba65a1d8cc +2026-07-23-client-plugin-loading-model.md: 21bad78792c6b5aad48b51f454f6c08c0400ad72 +2026-07-23-client-plugin-loading-model.zh.md: 2758f28f3bd34131ece3bed74152fbfe0b36174e diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 1d08aa7137..21bad78792 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -98,7 +98,7 @@ One governance implementation runs on both sides of the wire; the browser-specif Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows guide factory arrival but service availability remains the activation authority, so a mismatch appears at the settled sweep; the static UI libraries keep direct value exports; every bundle gains a source-map artifact; and external-script failures provide only coarse URL diagnostics instead of the HTTP status available to an explicit fetch. The Host retains per-plugin bundle/map snapshots, generated one-resource responses, current startup combo responses, and one previous startup generation, so memory scales as several copies of the composed client artifacts. This retained state keeps URLs immutable and lets an in-flight request finish across one HMR recomposition. -Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding belongs to the connection node half over `api-gateway` (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch plus SSE channel belongs to the hmr node half. +Roster: it lives in the web bundle's config tree (`packages/bundle/web-app/cordis.patch.yml`); `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer lives in the `dsh-client-modules` node half, while the parser-preloaded client face bootstraps the browser module table. The webserver remains a plain route-registration plugin; `/api/*` binding, browser authentication, RPC envelopes, and exact Fetch routes belong to the Connection node half, while Remote dispatch belongs to API Gateway and the dev bundle watch plus SSE channel belongs to the hmr node half. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 8aaa5d38f6..2758f28f3b 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -98,7 +98,7 @@ Wire 两侧运行同一份治理实现;浏览器特有层只包含一套模块 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;graph `inject` row 指导 factory 到达,但服务可用性仍是激活权威,因此不匹配会在 settled 扫描时浮出;静态 UI 库保留直接实体导出;每个 bundle 多出一份 sourcemap 产物,外部 script 失败也只能给出粗粒度 URL 诊断,不能像显式 fetch 那样报告 HTTP 状态。Host 会保留逐插件 bundle/map 快照、生成的单资源响应、当前启动 combo 响应及上一代启动响应,因此内存会随组合出的客户端产物增长为数份副本。这组保留状态使 URL 保持不可变,并让进行中的请求跨越一次 HMR 重组后仍能完成。 -名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定属于 connection node 半,并经 `api-gateway`(由 `dsh-host-apiproxy` 提供 `ctx.apiProxy`);开发期 bundle 监视与 SSE 通道属于 hmr node 半。 +名册位于 web 组合包的配置树(`packages/bundle/web-app/cordis.patch.yml`);`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于替换 yml/overlay。Graph 组合器位于 `dsh-client-modules` node 半,由 parser 预载的 Client face 则自举浏览器模块表。Webserver 继续作为朴素路由注册插件;`/api/*` 绑定、浏览器认证、RPC envelope 与精确 Fetch 路由属于 Connection node 半,Remote 分发属于 API Gateway,开发期 bundle 监视与 SSE 通道属于 HMR node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 983d460bb0..e935dd601a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 3d1ccc2a0f71411d496466288934d9038425e7cf -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: c2409e128dfdbd8550bb7052a7e0f67a40fe1d1f +2026-07-24-web-config-tree-boot-and-transport-layering.md: c44f68bb65758ec4879da8bded60f27f2f11e0bc +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 80f385a0a76a427c8f604a0df76decf77d2e62b0 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 3d1ccc2a0f..c44f68bb65 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -16,9 +16,9 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the activation audit. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. -**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. +**Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Session Controller consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**Transport responsibilities have explicit owners.** `dsh-client-connection` owns the `/api` route, request and response envelopes, browser authentication, Host/Origin checks, exact Fetch route registration, and the shared Typert interceptor seat. `dsh-api-gateway` owns typed Remote dispatch and the multiplexed WebSocket. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. Its socket-backed Node HTTP entry may apply configured gzip through maintained middleware without adding a response-writing service method or changing route owners; the Web Worker tunnel carries identity bytes. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. @@ -33,7 +33,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) | Rejected | One-line reason | |---|---| | Dedicated `dsh-host-profile` receiver package | User model state belongs to the Settings-backed `ctx.agentDefaultModel`; an extra Host receiver would duplicate ownership and exclude direct entry points | -| Runtime `assembly` shim plugin providing an `apiHandler` service | Existed only because `createApiProxy` lived in runtime; moving it into apiproxy made the gateway self-hosting, and `toFetchHandler` is a pure function the binding side calls | +| Runtime `assembly` shim plugin providing an `apiHandler` service | Connection already composes Remote interception and feature-owned exact Fetch routes into one handler at the transport edge | | Full-rescan + incremental scan coexisting | Two implementations, two semantics; the single per-package path covers the activation pass too | | A bespoke `./impl` export on the modules package | Non-uniform exports; the standard `./client` carries the whole browser half | | dev overlay / `cordis.dev.yml` | One yml; `!!js` cannot conditionalize row existence, and `--dev` appending one row is the entire difference | diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index c2409e128d..80f385a0a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -16,9 +16,9 @@ Status: implemented **boot 胶水由两个类组成。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有那些必须独立于 cordis、提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest(元数据清单)、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加 activation audit。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐一创建图行、settle、sweep。 -**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Web 网关消费同一份状态。 +**每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.zh.md)与 Session Controller 消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route 所有者;Web Worker 隧道传递 identity 字节。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输职责各有明确 owner。** `dsh-client-connection` 持有 `/api` 路由、请求与响应 envelope、浏览器认证、Host/Origin 检查、精确 Fetch 路由注册以及共享 Typert interceptor 席位。`dsh-api-gateway` 持有类型化 Remote 分发和多路复用 WebSocket。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。其基于 socket 的 Node HTTP 入口可以通过受维护的中间件应用已配置的 gzip,无需新增响应写出服务方法或改变 route owner;Web Worker 隧道传递 identity 字节。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)持有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换)node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由持有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 @@ -33,7 +33,7 @@ Status: implemented | 弃案 | 一行理由 | |---|---| | 专门的 `dsh-host-profile` 受体包 | 用户模型状态归 Settings 支撑的 `ctx.agentDefaultModel` 所有;额外的 Host 受体会重复归属,并排除直接入口 | -| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | 它的存在只因 `createApiProxy` 住运行时;本体迁入 apiproxy 后网关可自承载,且 `toFetchHandler` 是绑定方自己调的纯函数 | +| 运行时里的 `assembly` 垫层插件(provide `apiHandler`) | Connection 已在传输边缘把 Remote interception 与功能自有的精确 Fetch 路由组合成一个 handler | | 全量重扫与增量扫描并存 | 两条实现两份语义;单包路径足以覆盖激活初扫 | | modules 包特设 `./impl` 出口 | 出口不统一;标准 `./client` 承载完整浏览器半 | | dev overlay / `cordis.dev.yml` | 一套 yml;`!!js` 无法条件化行存在性,`--dev` 追加一行就是全部差异 | diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml index 5d8fa99508..d4ca06ac71 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md -2026-07-28-api-browser-trust-boundary.md: 4401dc8e361281b1239eb84319fc5353948bc217 -2026-07-28-api-browser-trust-boundary.zh.md: c1db2632a6019585ab6a948bcb0f4f2ef853b8a7 +2026-07-28-api-browser-trust-boundary.md: 397c93e084f84579ce3f90654f514428e696ffcd +2026-07-28-api-browser-trust-boundary.zh.md: 6d3896fd2c1fd6c607b9d92376eee76ab03df3c7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md index 4401dc8e36..397c93e084 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md @@ -12,7 +12,7 @@ The web GUI host serves `/api` over plain loopback HTTP (default `127.0.0.1:3080 Enforce browser trust once, at the carrier, for the entire `/api` prefix — two halves: -- **Media-type fence (dsh-host-apiproxy)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. +- **Media-type fence (dsh-client-connection)**: every `/api` POST must declare `application/json`, else 415 before parsing. Cross-site "simple" requests thereby stop existing: any cross-site attempt is forced into a CORS preflight this server never answers. - **Authority fence (dsh-client-connection, `src/api-request-trust.ts`)**: every request must present a `Host` that is loopback or matches a `trustedHosts` entry (exact on `host:port`, any port on port-less entries, WHATWG-normalized; rebinding defense). Deliberately no shortcut for unmarked requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may be a rebound browser read whose response the page can read, and Host is the one header rebinding cannot forge; non-browser clients pass via loopback, the derived LAN IP literals, or a declared authority. An attached `Origin` must equal the Host authority; `sec-fetch-site: cross-site` is refused outright. A `trustedHosts` entry that is not a bare, canonical authority fails the plugin load — WHATWG parsing would otherwise quietly authorize the hostname inside a typo or broaden an exact-port grant. `host.pickDirectory` loses its bespoke guard and rides the same fence. Reachability is the webserver binding's policy (`host: 127.0.0.1 | 0.0.0.0`), and this fence is a confused-deputy defense rather than identity. Connection applies the separate [browser token authentication](2026-08-24-browser-token-authentication.md) after the fence. The fence does not inspect peer socket addresses: binding expresses reachability, `trustedHosts` names accepted authorities, and the socket address adds nothing the Host/Origin checks need. diff --git a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md index c1db2632a6..6d3896fd2c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md @@ -12,7 +12,7 @@ Web GUI 宿主以纯 loopback HTTP 提供 `/api`(默认 `127.0.0.1:3080`;CLI 在载体层对整个 `/api` 前缀一次性执行浏览器信任检查——分为两部分: -- **媒体类型栅栏(dsh-host-apiproxy)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 +- **媒体类型栅栏(dsh-client-connection)**:每个 `/api` POST 必须声明 `application/json`,否则在解析前以 415 拒绝。跨站「简单请求」由此不复存在:任何跨站尝试都被逼进一次本服务器从不应答的 CORS 预检。 - **权威栅栏(dsh-client-connection,`src/api-request-trust.ts`)**:每个请求的 `Host` 都必须是回环地址,或与某个 `trustedHosts` 条目匹配(带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,均经 WHATWG 归一化;rebinding 防御)。刻意不为无标记请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求可能是被重绑页面发起且响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、推导的 LAN IP 字面量或已声明的权威通过。若带 `Origin` 则必须与 Host 权威完全一致;`sec-fetch-site: cross-site` 一律拒绝。不是单纯规范化 authority 的 `trustedHosts` 条目会导致插件加载失败——否则 WHATWG 解析会悄悄授权笔误里的 hostname,或放大精确端口授权。`host.pickDirectory` 失去专属守卫,与其他请求同栅而行。 可达性由 webserver 的绑定配置(`host: 127.0.0.1 | 0.0.0.0`)控制,这道栅栏是混淆代理人防御,而不是身份。Connection 在栅栏之后应用独立的[浏览器令牌认证](2026-08-24-browser-token-authentication.zh.md)。栅栏不检查对端 socket 地址:绑定表达可达性,`trustedHosts` 点名接受的 authority,socket 地址提供不了 Host/Origin 校验需要的额外信息。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index a72957b18a..d09e7e13cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 423fec3ad517e645f1cdee3513bad312a56989a8 -2026-07-28-directory-picker-capability-seam.zh.md: f652157fc152dee44a50ab8b55cc6120d92a1a26 +2026-07-28-directory-picker-capability-seam.md: 10ab8e393d5d33da6a09af5caa4024f02c57165c +2026-07-28-directory-picker-capability-seam.zh.md: 8764dc208bef1ead3fc1ef608c4d3b2b37400602 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 423fec3ad5..10ab8e393d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -10,9 +10,9 @@ The web GUI's "Open local folder" flow was hardwired to one interaction: `host.p ## Decision -A three-package capability seam in `packages/host/` — `directory-picker` (Service Definition), `directory-picker-native`, `directory-picker-browse` (backends) — with one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. The gateway (`dsh-host-apiproxy`) injects `directoryPicker`, serves the matching RPCs, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. +A three-package capability seam in `packages/host/` — `directory-picker` (Service Definition), `directory-picker-native`, `directory-picker-browse` (backends) — has one contract method: `capability()` returns a **discriminated union**, `{ kind: 'native', pick(signal) }` or `{ kind: 'browse', list(path?), createDirectory(path, name) }`. `DirectoryPickerController` in `dsh-api-workspace-controller` injects `directoryPicker`, serves the matching generated Remote methods, and answers `directory-picker-unavailable` for the other kind. The union is discriminated because the backends differ in *interaction shape* — flattening them into one method set would force every backend to fake the other's shape. -**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `host.pickDirectory`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. +**The client side is slot-composed, not advertisement-branched.** ui-workspace's two trigger surfaces each declare a `single` directory-flow hole (`conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`; two keys because a hole has exactly one declaring slot entry — same owner contract, same occupant). Backend packages are **dual-face**: the browser half registers the matching interaction into both holes — `-native` a renderless occupant driving `directoryPicker/pick`, `-browse` the in-app Select Workspace Directory dialog. The hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`) carries the whole exchange: ui-workspace keeps the trigger (menu entry rendered only while the hole is occupied) and the adoption (`createWorkspace({path})`, retryable error dialog, Choose again), the occupant owns everything between `open` and the picked path. One `cordis.yml` row therefore swaps the host capability and the client flow together; a mismatch is impossible by construction, and mounting two flow packages fails at client load (`single` hole). The earlier `host.describe.directoryPicker` advertisement and the client's kind branching are deleted — with composition wiring both sides, a wire fact for the client to branch on had no remaining consumer. The hole registry (`ctx.slots.entries`) replaces it as the per-menu-open occupancy read. Placement and policy rulings folded into this decision: @@ -31,7 +31,7 @@ Placement and policy rulings folded into this decision: - **Extend `ctx.fs` with browse methods.** Rejected: authority-domain coupling above; also a listing-for-display contract (hidden flags, crumbs, home anchor) does not belong on a storage seam. - **One uniform Service Definition method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. -- **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. +- **Direct stdlib calls inside the API adapter (no seam).** Rejected: keeps the adapter the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. @@ -43,6 +43,6 @@ Placement and policy rulings folded into this decision: ## Consequences - `cordis.yml` chooses the interaction; `apps/cli` mounts the [`-auto` chooser](../feature/2026-07-29-directory-picker-adaptive-default.md), which resolves the host's situation at boot and mounts `-native` or `-browse` itself, one row still swapping backend and UI together; composing a backend row directly pins the interaction. -- The wire gains `host.listDirectory`/`host.createDirectory` and four error codes; the connection fixture serves a deterministic browse tree and a deterministic `pickDirectory` path for keyless assembled tests. +- The wire exposes generated `directoryPicker/list` and `directoryPicker/createDirectory` methods with four error codes; the Connection fixture serves a deterministic browse tree and `directoryPicker/pick` result for keyless assembled tests. - A future interaction (or an Electron provider of the `native` interaction) is one dual-face backend package — no gateway surgery, no ui-workspace edits. - `ApiProxyDefaults.pickDirectory` (test-only injection) is gone; tests provide a stub `ctx.directoryPicker` like any other service. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index f652157fc1..8764dc208b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -10,9 +10,9 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. ## 决策 -在 `packages/host/` 落一个三包能力 seam——`directory-picker`(Service Definition)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一约定方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。网关(`dsh-host-apiproxy`)注入 `directoryPicker`,提供对应的 RPC,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 +在 `packages/host/` 落一个三包能力 seam——`directory-picker`(Service Definition)、`directory-picker-native`、`directory-picker-browse`(后端)——唯一约定方法 `capability()` 返回**可辨识联合**:`{ kind: 'native', pick(signal) }` 或 `{ kind: 'browse', list(path?), createDirectory(path, name) }`。`dsh-api-workspace-controller` 中的 `DirectoryPickerController` 注入 `directoryPicker`,提供匹配的生成 Remote 方法,另一种 kind 的调用以 `directory-picker-unavailable` 应答。联合之所以可辨识,是因为后端差异在**交互形态**——压平成统一方法集会逼每个后端伪装另一方的形态。 -**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是**双面包**:浏览器一侧把匹配的交互注册进两个洞——`-native` 是驱动 `host.pickDirectory` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 +**client 侧靠 slot 组合,而非按广播分支。** ui-workspace 的两个触发表层各自声明一个 `single` 目录流洞(`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`;之所以是两个 key,是因为一个洞只有一个声明它的 slot entry——owner 约定相同、占用者相同)。后端包是**双面包**:浏览器一侧把匹配的交互注册进两个洞——`-native` 是驱动 `directoryPicker/pick` 的无渲染占用者,`-browse` 是应用内的选择工作区目录对话框。洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)承载整个交换:ui-workspace 保留触发(菜单入口仅在洞被占用时渲染)与接纳(`createWorkspace({path})`、可重试的错误对话框、重新选择),占用者持有从 `open` 到所选路径之间的一切。因此一行 `cordis.yml` 同时切换宿主能力与 client 流程;错配在构造上不可能,同时挂两个流程包会在 client 加载期失败(`single` 洞)。早先的 `host.describe.directoryPicker` 广播与客户端 kind 分支被删除——组合已经接好两侧后,供客户端分支用的 wire 事实不再有任何消费者。洞注册表(`ctx.slots.entries`)取而代之,成为每次打开菜单的占用读取。 并入本决策的位置与策略裁决: @@ -31,7 +31,7 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. - **给 `ctx.fs` 增加浏览方法。** 否决:上述权限域耦合;且面向展示的列举约定(hidden 标志、面包屑、home 锚点)不属于存储 seam。 - **统一的 Service Definition 方法集(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而原生选择器实现不了原语。交互差异不可约,故用判别标签。 -- **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture(测试前置数据)/测试后端,与促成这项工作的插件教义相悖。 +- **API adapter 里直接调标准库(不建 seam)。** 否决:换装点仍是改 adapter 源码,失去 fixture(测试前置数据)/测试后端,与促成这项工作的插件教义相悖。 - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的「显示隐藏」开关(「隐藏隐藏文件」)。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 @@ -43,6 +43,6 @@ web GUI 的「打开本地文件夹」流程被焊死在一种交互上:`host. ## 后果 - `cordis.yml` 决定交互形态;`apps/cli` 挂 [`-auto` 选择器](../feature/2026-07-29-directory-picker-adaptive-default.zh.md),它在启动时判定宿主处境并自行挂载 `-native` 或 `-browse`,一行仍同时切换后端与 UI;直接组合某个后端行即固定交互。 -- 协议新增 `host.listDirectory`/`host.createDirectory` 与四个错误码;connection fixture 提供确定性浏览树与确定性 `pickDirectory` 路径供无密钥组装测试使用。 +- 协议公开生成的 `directoryPicker/list` 与 `directoryPicker/createDirectory` 方法及四个错误码;Connection fixture 提供确定性浏览树与 `directoryPicker/pick` 结果供无密钥组装测试使用。 - 未来的新交互(或提供 `native` 交互的 Electron 提供方)只是一个双面后端包——无需网关手术,也不动 ui-workspace。 - `ApiProxyDefaults.pickDirectory`(仅测试注入)删除;测试像提供其他服务一样提供 stub `ctx.directoryPicker`。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index 35802e4e80..8baf8386d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md -2026-07-29-projected-token-usage-and-request-context.md: 063f2300f378f6f7763bce87b11add5da3093230 -2026-07-29-projected-token-usage-and-request-context.zh.md: 37b8741d09e9ec56f6b9f273e05460b2deb4f6f9 +2026-07-29-projected-token-usage-and-request-context.md: 75a05e5a0e8f0183fef1e7d80701ce6d81041cd6 +2026-07-29-projected-token-usage-and-request-context.zh.md: 7cce5989d719156f1d66c48937780ff8aed02a42 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index 063f2300f3..75a05e5a0e 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -40,7 +40,7 @@ The non-atomicity is deliberate, not a defect. A consumer that genuinely needs a **An atomic request-boundary snapshot delivered as a transient mux frame (implemented, then rejected).** An earlier revision emitted `session/model-request`: one non-replayable frame carrying `contextTokens` and `contextWindow` measured at the same `agent/model-request` boundary. Being the only non-replayable class on the mux stream is what broke it. Host and mux are independent SSE streams with no cross-stream ordering, so a request emitted before a removal could arrive after `host/session-removed` and revive a dead session's telemetry, while a legitimate request for a new lifecycle reusing the same id could be fenced by a late removal. `session/subscribed` is not lifecycle proof — it says a queue began subscribing to an id, not that a new in-memory session replaced an older one — and `lastSeq` is a durable watermark two lifecycles can share. A correct fix required a monotonic lifecycle generation on the frame, on subscription, and on removal, plus a client watermark comparison. -That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made ApiProxy a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. +That cost bought a worse display: occupancy went blank after every reconnect and never moved while a conversation grew. It also made the transport adapter a measurement site calling the O(surface) `measure()` on every request, and expressed reconnect state through a synthetic `cancelled` open error the UI had to special-case. **Fold the loaded node window in React.** Cannot survive pagination or compaction, and makes a presentation package reconstruct log semantics. @@ -58,4 +58,4 @@ Token totals stay stable across pagination, compaction, replay, restart, and rec Occupancy is approximate in the ways documented above. It is available immediately after restore or reconnect, since both fields are durable, at the cost of describing the last recorded request rather than an exact current boundary. -Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. ApiProxy carries no token-specific code, owns no per-session metrics cache, and performs no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. +Each session log gains one small `request/context` record per route or advertised-capacity change. Token-meter is the canonical owner of durable usage semantics, including retry-attempt separation in the cumulative projection and the reusable exact attempt/Turn fold; Web Chat only selects a complete loaded Turn and renders the fold result. The TUI retains its live per-step map because it does not mount the generic projection seam, and the standalone browser fixture mirrors the unit. Connection and API Gateway carry no token-specific code, own no per-session metrics cache, and perform no measurement. The browser keeps two generic projection values and no connection-local telemetry, and streaming text deltas still do not force the stats line to recompute. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 37b8741d09..7cce5989d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -40,7 +40,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节 **以临时 mux 帧交付请求边界上的原子快照(已实现,随后否决)。** 较早的一个修订版会发出 `session/model-request`:一个不可回放的帧,携带在同一个 `agent/model-request` 边界测得的 `contextTokens` 与 `contextWindow`。真正让它失效的,是它成了 mux 流上唯一的不可回放类别。Host 流与 mux 流是两条独立的 SSE(Server-Sent Events)流,彼此之间没有顺序保证:在移除之前发出的请求可能在 `host/session-removed` 之后才到达,让一个已死会话的遥测数据复活;而复用同一 id 的新生命周期的合法请求,又可能被一条迟到的移除拦下。`session/subscribed` 不能证明生命周期:它只说明某个队列开始订阅某个 id,而不说明新的内存会话替换了较早的会话;`lastSeq` 则是两个生命周期可以共用的持久水位线。正确的修法需要在帧上、订阅上和移除上都带一个单调递增的生命周期代次,再加上一次客户端水位线比较。 -这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把 ApiProxy 变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 +这份代价换来的是更差的显示:占用率在每次重连后变为空白,而且会话增长期间从不移动。它还把传输适配器变成一个测量点,每个请求都要调用 O(surface) 的 `measure()`,并通过一个 UI 必须特殊处理的、连接打开时的合成 `cancelled` 错误来表达重连状态。 **在 React 中归并已加载的节点窗口。** 无法跨分页或压缩保留数据,还会迫使展示包重建日志语义。 @@ -58,4 +58,4 @@ token 总量在分页、压缩、回放、重启和重连期间保持稳定, 占用率在上文记录的意义上是近似值。由于两个字段都是持久的,它在恢复或重连后立即可用;代价是它描述的是最后一条已记录的请求,而不是精确的当前边界。 -每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。ApiProxy 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 +每个会话日志会为每次路由或已公布容量变化增加一条小型 `request/context` 记录。token-meter 是持久用量语义的正典所有方,包括累计投影中的重试 attempt 分离,以及可复用的精确 attempt/Turn fold;Web Chat 只选择已完整加载的 Turn 并渲染 fold 结果。TUI 未挂载通用投影 seam,因此保留自己的实时逐步骤 map,而独立浏览器 fixture(测试前置数据)会镜像该单元。Connection 与 API Gateway 不携带任何 token 专用代码,不拥有逐会话指标缓存,也不执行测量。浏览器只保留两个通用投影值,不保留连接本地的遥测数据;流式文本增量仍不会迫使统计行重新计算。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index 21995b0838..ad1a5c6eb1 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: 97352a0e3376ce1fe26bac62b88c2c674202adc7 -2026-08-03-per-session-agent-presets.zh.md: b62baf3e3097ba99247c2e86cc3fb5b7e43e2fab +2026-08-03-per-session-agent-presets.md: 9d5fffbd4d69713fe733235cc0352bc93c9ce55c +2026-08-03-per-session-agent-presets.zh.md: 406d546828489ccd172205cde7d4b5e0ba96a39b diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index 97352a0e33..9d5fffbd4d 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -55,9 +55,9 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Connection authenticates authoring, `list`, `select`, and the complete Host API with one browser session: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability, while choosing a preset grants nothing `session.create` with `agentPreset` did not already grant. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `SubagentRuntime` is a Host row that exposes the browser's cross-session Remote queries (`list`, `prompt`), so it waited forever for a service only sessions provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. -**A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. +**A real-composition test that disables a host row cannot audit that row.** The web composition test keeps API Gateway and the domain Remote services active while disabling the transport-only webserver, Connection, Session export, asset, and telemetry rows. With the browse directory picker substituted, its startup audit covers the host-plane service graph without binding a port. **A preset's package names must resolve from the harness, not from the preset.** `EntryTree.import()` resolves a row against its own tree's `baseUrl`, which `Include` sets to the composition's directory. That is right for a relative specifier and fatal for a package name: a locally authored preset lives under the user's home, where Node's upward `node_modules` walk never reaches the installed harness, so every `@deepseek-ai/dsh-*` row fails to import and the whole preset is unmountable. The shipped presets hid this — they sit inside the install. The mount records the host composition's base before plugging the subtree and sends bare specifiers there, leaving relative paths resolving from the preset so its own files still travel with it. The real-composition test writing a preset into a temp root is what found it. @@ -67,7 +67,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **The choice belongs to the screen where it still works.** The composer seat spent almost its whole life disabled, since the preset is fixed once a turn has run. It moved to the new-session screen beside the workspace picker, where the pick is *staged*: that screen precedes the session it applies to, and the stage lands when a session becomes current and is still blank — covering both the session a workspace connect creates and the blank one it reuses, which riding `sessions.create` would miss. It is spent on first use, matching the workspace picker beside it. What a running session runs is then a read-only label in its header: a control there would promise a switch the host refuses outright. -**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `dsh-host-apiproxy` discards the `AgentHandle` it creates, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. +**A preset multiplies a cost the host was already paying: nothing disposes an agent.** Measured against the shipped compositions with `--expose-gc`, one live agent holds ~0.17 MB on `minimal` and ~1.31 MB on `standard`/`cordis`, mounting in ~38 ms and ~135 ms; the first agent of a process costs ~7 MB more as Node imports the modules, which every later mount then shares. Growth is strictly linear — 10, 30 and 50 agents give the same per-agent delta — and disposal reclaims essentially all of it (50 `standard` agents held 57.8 MB and returned it). So the object graph does not leak; the lifecycle does. `ApiSessionAgentController` discards the `AgentHandle` returned by the registry, `archiveSession` only edits the workspace registry, `AgentRegistry` has no eviction, and the sole disposal site in the host is the JSON-RPC server's own shutdown. A web host therefore retains every session it has touched, at ~1.3 MB each once presets are composed rather than ~0.2 MB before. Note that pruning the mount registry does not help here: it drops records whose fiber `uid` has cleared, and an agent that never dies never clears one. - Remaining TODO: idle agent eviction — dispose after the session is persisted and re-mount on resume. It belongs to the host that owns the handle, not to this seam. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index b62baf3e30..406d546828 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -56,9 +56,9 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。Connection 用一个浏览器会话认证创作操作、`list`、`select` 与完整 Host API:组装指明一个会话所运行的插件,因此读取它是侦察,写入它是任意能力;选择 preset 则没有授予 `session.create` 携带 `agentPreset` 时尚未拥有的能力。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`SubagentRuntime` 是 Host 行,它公开浏览器的跨会话 Remote 查询(`list`、`prompt`),因而永远等待一个只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.zh.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 -**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 +**真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试保持 API Gateway 与各业务 Remote 服务启用,同时禁用只承载传输的 webserver、Connection、Session export、资源与遥测行。替换为 browse 目录选择器后,其启动审计无需绑定端口即可覆盖 Host 平面的服务图。 **preset 的包名必须从 harness 解析,而非从 preset 解析。** `EntryTree.import()` 按行所属树的 `baseUrl` 解析,而 `Include` 把它设为组装文件所在的目录。这对相对标识符是对的,对包名却是致命的:本地创作的 preset 位于用户主目录之下,Node 向上查找 `node_modules` 永远够不到已安装的 harness,因此每一个 `@deepseek-ai/dsh-*` 行都会导入失败,整个 preset 无法挂载。随部署提供的 preset 掩盖了这一点——它们本就在安装目录之内。挂载在插入子树之前先记录宿主组装的基址,并把裸标识符送往那里,同时让相对路径继续从 preset 解析,使它自带的文件仍随它一同迁移。发现它的正是那个把 preset 写入临时根目录的真实组装测试。 @@ -68,7 +68,7 @@ Status: implemented **这个选择属于它仍然可用的那个界面。** composer 座位几乎一生都处于禁用状态,因为一旦跑过一个轮次,preset 即固定。它移到了新建会话界面、工作区选择器旁边,选择在那里是**暂存**的:该界面先于它要应用到的会话存在,暂存值在某个会话成为当前会话且仍为空白时落地——这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。它一经使用即被清空,与旁边的工作区选择器一致。至于运行中的会话在跑什么,则是其标题旁的一个只读标签:在那里放控件,等于承诺一次宿主会断然拒绝的切换。 -**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`dsh-host-apiproxy` 创建后直接丢弃 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 +**preset 放大的是宿主本来就在付的代价:没有任何东西会 dispose 一个 agent。** 用 `--expose-gc` 对随附组装实测:一个存活的 agent 在 `minimal` 上约占 0.17 MB、在 `standard`/`cordis` 上约 1.31 MB,挂载耗时分别约 38 ms 与 135 ms;进程里第一个 agent 另需约 7 MB,那是 Node 首次 import 模块的一次性成本,此后每次挂载共享。增长严格线性——10、30、50 个的单个增量一致——且 dispose 后基本全额回收(50 个 `standard` 占住 57.8 MB,释放后全部归还)。所以对象图并不泄漏,缺的是生命周期。`ApiSessionAgentController` 会丢弃注册表返回的 `AgentHandle`,`archiveSession` 只改工作区注册表,`AgentRegistry` 没有驱逐机制,而宿主里唯一一处 dispose 是 JSON-RPC 服务器自身的关停。于是一个 web 宿主会留住它接触过的每一个会话,组装 preset 之后每个约 1.3 MB,而在此之前约 0.2 MB。注意:剪枝挂载注册表在这里没有用——它丢弃的是 fiber `uid` 已清空的记录,而永不死亡的 agent 永远不会清空它。 - 遗留 TODO:idle agent 驱逐——会话持久化后 dispose,恢复时重新挂载。它属于持有 handle 的那个宿主,不属于本 seam。 diff --git a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml deleted file mode 100644 index 8ded320f05..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-04-websocket-downlink-carrier.md -2026-08-04-websocket-downlink-carrier.md: 420a0d30f31cca58448adc0afd46a5d6d5e9107f -2026-08-04-websocket-downlink-carrier.zh.md: 5f277a4ed33ffe97b51587fef960746b93eff1c1 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index 12f13004b8..d5eccf48bc 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 9c17b8d418924c38174b4d958fd54b057b118019 -2026-08-09-headless-direct-core-entry-point.zh.md: d95978a832d52b26b1139cabb4b23ade93ce0da3 +2026-08-09-headless-direct-core-entry-point.md: 0234f8b7843be172eafe8bd4c38b3544f5cfb07b +2026-08-09-headless-direct-core-entry-point.zh.md: f864c94d69adc67fa0d3836af834e1ebde151063 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 9c17b8d418..0234f8b784 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -6,21 +6,21 @@ English | [中文](2026-08-09-headless-direct-core-entry-point.zh.md) ## Problem -The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, ApiProxy, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. +The `headless` product contract is one local task with final assistant text on stdout, a success-sensitive exit code, no listening port, and the stderr reasoning projection owned by [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md). A composition containing Workspace Host services, browser RPC, HTTP, the Web runtime, or browser plugins contradicts that contract and makes local completion depend on an unrelated transport tree. The direct entry point still needs the same deployment model state as Web-created Agents. A separate provider/model default would give one deployment two answers, while deriving completion before the Agent and Session persistence are quiescent permits stdout and the exit code to observe incomplete state. ## Decision -The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no `@deepseek-ai/dsh-host-*` package, ApiProxy, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. +The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no browser Connection, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. `headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. [Headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns the live stderr projection; a terminal `error` reason writes its durable code and message there, and unexpected driver failures also use stderr and exit 1. -`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct and ApiProxy entry points consume this service; ApiProxy alone owns session-local precedence, model validation, and persistence of accepted Web selections. +`@deepseek-ai/dsh-agent-default-model` owns the transport-independent default used for an Agent without a session-local selection. `AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers the `agent-default-model` Settings section. Composition config supplies `{provider, model}`; user settings may also supply `reasoningEffort`. `currentSelection()` returns the live complete selection and `saveSelection()` writes it as a complete section, so a selection without an effort clears any stored effort. `dsh-base` supplies the composition entry. Direct creation and Session Controller Remote calls consume this service; the Session Controller owns session-local precedence, model validation, and persistence of accepted Web selections. `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts; [headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns successful stderr output. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification @@ -31,14 +31,14 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Alternative | Contract mismatch | |---|---| | Keep `dsh-web-app` but suppress its observation line | The process still opens a port and carries the Host, Web, and browser trees. | -| Build a Host-only one-shot bundle around ApiProxy | ApiProxy is a client protocol gateway; a local one-shot entry point has no client boundary. | -| Use `InProcessApiClient` for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | +| Build a Host-only one-shot bundle around browser RPC | A local one-shot entry point has no client boundary. | +| Use the in-process Connection carrier for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | | Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | | Normalize every tuple containing Web and headless bundles | Bundle lists are an extension surface; only the exact installation-owned tuple is safe to classify. | ## Consequences -`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Text-only successful runs leave stderr empty, reasoned runs stream the provider-reported content there, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no browser request id. -ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. +Connection carrier coverage stays in the Connection package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index d95978a832..f864c94d69 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -6,21 +6,21 @@ Status: implemented ## 问题 -`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、ApiProxy、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 +`headless` 的产品约定是一个本地任务:最终 assistant 文本写入 stdout,退出状态反映成功与否,不打开监听端口,并由 [headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责 stderr 推理投影。包含 Workspace Host 服务、浏览器 RPC、HTTP、Web 运行时或浏览器插件的组合违背这一约定,也使本地完成状态依赖无关的传输树。 直接入口仍需要与 Web 所创建 Agent 相同的部署模型状态。独立的提供方/模型默认值会让同一部署产生两种答案,而在 Agent 与会话持久化完全停稳之前推导完成状态,会让 stdout 与退出状态观察到不完整状态。 ## 决策 -随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含任何 `@deepseek-ai/dsh-host-*` 包、ApiProxy、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 +随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含浏览器 Connection、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。[Headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责实时 stderr 投影;结束原因为 `error` 时,其持久化错误码与消息写入 stderr,驱动器的意外失败也写入 stderr 并以 1 退出。 -`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接入口与 ApiProxy 入口均消费该服务;只有 ApiProxy 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 +`@deepseek-ai/dsh-agent-default-model` 拥有与传输无关的默认值,供没有会话级选择的 Agent 使用。`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel` 并注册 `agent-default-model` Settings 分节。组合配置提供 `{provider, model}`,用户设置还可以提供 `reasoningEffort`。`currentSelection()` 返回当前的完整选择,`saveSelection()` 则写入完整分节,因此不含强度的选择会清除已存强度。`dsh-base` 提供组合条目。直接创建与 Session Controller Remote 调用均消费该服务;Session Controller 负责会话级优先级、模型校验与已接受 Web 选择的持久化。 `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.zh.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定;[headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责成功运行时的 stderr 输出。[应用持有自己的命令行](2026-08-06-app-owned-command-line.zh.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.zh.md)负责共享 Agent 默认值的持久化。 ## 验证 @@ -31,14 +31,14 @@ Status: implemented | 替代方案 | 约定不匹配之处 | |---|---| | 保留 `dsh-web-app`,但隐藏观察行 | 进程仍会打开端口并携带 Host、Web 与浏览器插件树。 | -| 围绕 ApiProxy 构建纯 Host 一次性组合包 | ApiProxy 是客户端协议网关,而本地一次性入口没有客户端边界。 | -| 使用 `InProcessApiClient` 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | +| 围绕浏览器 RPC 构建纯 Host 一次性组合包 | 本地一次性入口没有客户端边界。 | +| 使用进程内 Connection carrier 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | | 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | | 规范化所有包含 Web 与 headless 组合包的元组 | 组合包列表是扩展面;只有精确的安装过程所属元组可以安全分类。 | ## 后果 -`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。没有推理内容的成功运行会保持 stderr 为空,有推理内容的运行则在那里流式输出提供方报告的内容;完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带浏览器 request id。 -ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 +Connection carrier 覆盖保留在 Connection 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml index 33a31a1363..a1f574e789 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.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-10-remote-event-delivery.md -2026-08-10-remote-event-delivery.md: 5e6e04bdf2c6b685bbf10f05ede9c96ea8104429 -2026-08-10-remote-event-delivery.zh.md: d744b92d47f5778397b519fa09d4b91f620dcd7e +2026-08-10-remote-event-delivery.md: 4b9c2f224e36fb97f798492c999726eb55bad214 +2026-08-10-remote-event-delivery.zh.md: 4ca1f8c244df4e985d1223b6c468b450be943abf diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md index 5e6e04bdf2..4b9c2f224e 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.md @@ -70,9 +70,9 @@ $on(event: Event, listener: TypertClientEventLi **The contract exposes only the consumer verb.** `ClientRemoteService` registers the one internal `$events` pump as a Connection generation source when it activates, independently of whether any `$on` subscription exists. Browsers open `$events` through the shared Remote mux; in-process compositions open the same logical stream through `connection.rpc.open`. Decoding, exact item validation, and Cordis dispatch are private Gateway Client implementation. `TypertClientRemote` exposes no producer operation, so a business plugin cannot synthesize a Host event. -Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready' }` before iterating the event source. `ConnectionController` waits for that ready item and `host.describe` in parallel and publishes `connected` only after both succeed, so baseline reads cannot race ahead of incremental listeners. +Each time the Host opens `$events`, the API Remotes source factory installs every allowlist listener synchronously. Gateway then yields the opening `{ type: 'ready', clientId, host: { home } }` before iterating the event source. `ConnectionController` publishes `connected` only after that item arrives, so baseline reads cannot race ahead of incremental listeners. -A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation's `hostDescription` and reopens `$events` and `host.describe` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. +A physical mux disconnect ends the logical stream with `RemoteStreamCarrierError`. A Host Remote stream error, unexpected normal completion, non-ready opening item, or malformed event item also ends the current generation. Connection withdraws that generation and reopens `$events` after backoff; Gateway mux only rebuilds the physical WebSocket. Ordinary events are not replayed. State whose correctness requires recovery must provide a query, cursor, or opening baseline and cannot treat `$on` as a reliable journal. The Client dispatches on a Cordis key private to each Remote instance. Ordinary `emit` uses `parallel()` and contains listener failures; Agent-scoped `waterfall` uses `waterfall()` on the resolved Agent Context and allows a result, rejection, or `next()` delegation. Both registration kinds belong to the calling fiber, and Host events do not trigger same-named Client-local events. @@ -132,13 +132,13 @@ cancel { type, eventId } The Client opens internal logical stream `$events` with payload `{ args: {} }`. Gateway rejects extra parameters, a missing Host source, and duplicate source registration. Withdrawing a source aborts every stream opened by that registration. Each Client stream owns an independent queue and allowlist listener set in `api/remotes`, so disconnecting one Client neither consumes nor withdraws another Client's events. -The Client requires an opening `ready` item with a non-empty `clientId`; every later item is checked for exact fields by discriminant. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. +The Client requires an opening `ready` item with a non-empty `clientId` and `host.home`; every later item is checked for exact fields by discriminant. The ready item establishes the Connection generation and supplies the stable Host path-display fact. An ordinary `emit` with an unknown but structurally valid event name is dropped when there is no subscriber. Waterfalls use `eventId` to correlate `$events/result` and `agentId` to select a Client Agent Context. The Client returns only values representable as lossless JSON; transport does not reinterpret business fields. `$events` is an internal Gateway endpoint. It does not enter a generated Typert Remote descriptor or become `ctx.remote.`. Application selection exists only in the API Remotes allowlist and Host source; Gateway owns registration, payload validation, and physical transport only. ### The `apps/web` browser e2e belongs to the Host face -The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.apiProxy`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces 21 errors because one program cannot hold both faces' merges for the same Context key. +The `apps/web/tests/**` e2e files typecheck in root `tsconfig.host.json`: they boot a real harness in process and directly access `ctx.connection`, Host `SessionStore.get/create/flush`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not place a file in the Client TypeScript program. Moving these tests to the Client aggregate produces errors because one program cannot hold both faces' merges for the same Context key. This implies one build rule needed by the design: importing a value or type from a Client package in those tests brings that package's whole project and all its project references into the Host build graph. Four consumers (`ui-settings-general`, `ui-settings-models`, `ui-permission`, and `ui-commands`) reference API Remotes' Client face, which cannot compile until Host tsdown generates `@deepseek-ai/dsh-goal/remote`. That forms a build-order cycle: Host tsc needs API Remotes Client, which needs generated `goal/remote`, which Host tsdown emits after Host tsc. @@ -153,11 +153,10 @@ The few required Client symbols are mirrored on the test side: `scaffold.ts` exp | `api/remotes` | `src/remote-events.ts` (mode-bearing allowlist value) and `src/types.ts` (key projection and selection) belong to both faces; Host registers each Client source and validates JSON before queueing; Client continues to compose generated Remote contributions | | Root `tsconfig.base.json` | Adds source-plane `paths` entries for `dsh-settings/types`, `dsh-credentials/types`, and `dsh-api-remotes/types` | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | Moves each `interface Events` member to the owner's Client-safe `./types`; settings and credentials add that export, move brands and pure types with it, retain constructors in index, and include `lib/types/**/*.js` in published files | -| `host/apiproxy` | Contains no `HostFrame`, `events.host()`, or other Host downlink carrier; API Proxy does not participate in Host events or Connection generation | | `dsh-session` | Exposes `isJsonValue` for validation of every event argument by the API Remotes Host source | | `client/runtime` | Removes the bridge from Host frames to the Remote subscription table; it only publishes `connection/reset` after a Connection generation is established | | Consumers | Client plugins subscribe directly through `ctx.remote.$on(...)`, import owner event declarations type-only, and inject `'remote'` | -| `client/connection` | Provides the one generation-source registration point; `ConnectionController` combines `$events` ready with `host.describe`, and the fixture emits events from the same source | +| `client/connection` | Provides the one generation-source registration point; `ConnectionController` publishes the Host facts from `$events` ready, and the fixture emits events from the same source | | `apps/web/tests` + `apps/cli` | Mirrors Client symbols on the test side as described above and removes 15 Client project references from `apps/cli/tsconfig.json` | ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md index d744b92d47..4ca1f8c244 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-remote-event-delivery.zh.md @@ -70,9 +70,9 @@ $on(event: Event, listener: TypertClientEventLi **契约只公开消费动词。**`ClientRemoteService` 激活时就把内部唯一的 `$events` pump 注册为 Connection generation source,与当前有无 `$on` 订阅无关;浏览器通过共享 Remote mux 打开 `$events`,进程内组合通过 `connection.rpc.open` 打开同一 logical stream。解码、精确 item 校验和订阅表派发都是 Gateway Client 的私有实现,`TypertClientRemote` 不暴露生产方方法,因此业务插件不能伪造一条 Host 事件。 -每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready' }`,再开始迭代事件 source。`ConnectionController` 并行等待该 ready 与 `host.describe`,只有两者都成功才发布 `connected` 并允许 baseline 读取。这个顺序保证 baseline 不会跑在增量 listener 前面。 +每次 Host 打开 `$events` 时,API Remotes source factory 先同步挂载所有 allowlist listener,Gateway 随后产出首项 `{ type: 'ready', clientId, host: { home } }`,再开始迭代事件 source。`ConnectionController` 只有在该项到达后才发布 `connected`,因此 baseline 读取不会跑在增量 listener 前面。 -物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation 的 `hostDescription`,在退避后重开 `$events` 和 `host.describe`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 +物理 mux 断开会让 logical stream 以 `RemoteStreamCarrierError` 结束;Host 返回的 Remote stream error、意外正常结束、非 ready 首项或畸形事件项也会结束当前 generation。Connection 撤回该 generation,在退避后重开 `$events`;Gateway mux 只负责重建物理 WebSocket。转发事件不重放;凡正确性依赖恢复的状态,owner 必须另有查询、cursor 或 opening baseline,不能把 `$on` 当作可靠日志。 Client 以 Remote 实例私有 Cordis key 分发。普通 `emit` 使用 `parallel()` 并隔离 listener 失败;Agent-scoped `waterfall` 在解析出的 Agent Context 上使用 `waterfall()`,允许结果、拒绝或 `next()` 委托。两类注册都归属调用方 fiber,且 Host 事件不会触发 Client 本地同名事件。 @@ -132,13 +132,13 @@ cancel { type, eventId } Client 以 endpoint `$events` 和 payload `{ args: {} }` 打开 internal logical stream。Gateway 拒绝额外参数、缺失 Host source 和重复 source 注册;source 被撤回时会中止所有由该注册打开的 stream。每个 Client stream 在 `api/remotes` 中拥有独立队列与一组 allowlist listener,因此一个 Client 断开不会消费或撤销另一个 Client 的事件。 -Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discriminant 精确校验字段。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 +Client 要求首项是带非空 `clientId` 与 `host.home` 的 `ready`;后续 item 按 discriminant 精确校验字段。ready 项建立 Connection generation,并提供稳定的 Host 路径显示信息。普通 `emit` 的未知但结构合法事件名在没有订阅者时静默丢弃。waterfall 通过 `eventId` 关联 `$events/result`,并由 `agentId` 选择 Client Agent Context;Client 只回传可无损表示为 JSON 的结果,不在 transport 层重复解释业务字段。 `$events` 是 Gateway 内部 endpoint,不进入生成的 Typert Remote descriptor,也不成为 `ctx.remote.`。应用选择仍只存在于 `api/remotes` 的 allowlist 和 Host source;Gateway 只拥有注册、payload 校验与物理传输。 ### apps/web 的 browser e2e 属于 Host 面 -`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接摸 `ctx.apiProxy`、host `SessionStore.get/create/flush`、`ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 client 程序**——把它们搬进 client 聚合会立刻报 21 条错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 +`apps/web/tests/**` 那批 e2e 在**根 `tsconfig.host.json`** 做类型检查:它们在进程内起真 harness、直接访问 `ctx.connection`、Host `SessionStore.get/create/flush` 与 `ctx.sessionProjectionCache`。**运行时用浏览器 ≠ 类型上属于 Client 程序**——把它们搬进 Client 聚合会报错,因为一个 program 装不下两个 face 对同一个 Context key 的合并。 由此得到一条对本设计要紧的连带纪律:**这些测试从客户端包 import 值或类型,会把该包的整个 project——以及它引用的每个 project——拖进 Host 构建图**。`ui-settings-general`/`ui-settings-models`/`ui-permission`/`ui-commands` 四个消费者 references `api/remotes` 的 client face,而该 face 必须等 host tsdown 生成 `@deepseek-ai/dsh-goal/remote` 才能编译,于是形成构建期死锁:host tsc → api/remotes client face → `goal/remote` → host tsdown → 排在 host tsc 之后。 @@ -153,11 +153,10 @@ Client 要求首项是带非空 `clientId` 的 `ready`;后续 item 按 discrim | `api/remotes` | `src/remote-events.ts`(带 mode 的名单值)与 `src/types.ts`(键投影 + selection)双列进两个 face;Host 半注册每 Client source,并在入队前校验 JSON;Client 半继续组合生成的 Remote contribution | | 根 `tsconfig.base.json` | 加 `dsh-settings/types`、`dsh-credentials/types`、`dsh-api-remotes/types` 三条 `paths`,全部指向**源**平面 | | `dsh-commands` / `dsh-settings` / `dsh-credentials` | `interface Events` 子块移入各自 client-safe 的 `./types`(settings/credentials 新建该出口,brand 与纯类型一并移入,index 继续 re-export 并留住构造器;`files` 补 `lib/types/**/*.js`) | -| `host/apiproxy` | 不包含 `HostFrame`、`events.host()` 或其他 Host 下行 carrier;API Proxy 不参与 Host 事件或 Connection generation | | `dsh-session` | `isJsonValue` 供 `api/remotes` Host source 校验每个事件参数 | | `client/runtime` | 删除 Host frame 到 Remote subscription table 的桥;只继续在 Connection generation 建立后发布 `connection/reset` | | 消费方 | Client 插件直接订阅 `ctx.remote.$on(...)`,type-only 引入 owner 事件声明并把 `'remote'` 加进 `inject` | -| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 以 `$events` ready 与 `host.describe` 组成世代握手,fixture 也从同一 source 产生事件 | +| `client/connection` | 提供唯一 generation source 注册位;`ConnectionController` 发布 `$events` ready 携带的 Host 信息,fixture 也从同一 source 产生事件 | | `apps/web/tests` + `apps/cli` | 客户端符号镜像(见上节);`apps/cli/tsconfig.json` 删 15 条 client 工程引用 | ## 备选方案 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml index c316742ec1..5868c44f02 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.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-10-unary-apiproxy-remote-migration.md -2026-08-10-unary-apiproxy-remote-migration.md: 11254099556113d921da502f0150522886a718f3 -2026-08-10-unary-apiproxy-remote-migration.zh.md: 50b303876863e992566f6ed6fb0bd0a89326344f +2026-08-10-unary-apiproxy-remote-migration.md: b98c7ee95b61ec00a5cab3106e812a6f17fc0a15 +2026-08-10-unary-apiproxy-remote-migration.zh.md: 74bca8fd72f3e41075f5a44eba116fe127343bb2 diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md index 1125409955..b98c7ee95b 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md @@ -12,9 +12,9 @@ Agent-bound calls require particular care. Shared lookup policy reuses live Agen ## Decision -Simple unary operations live on their natural business Remote owner. The business package owns the Remote signature and Host adaptation; `@deepseek-ai/dsh-api-remotes/client` selects its generated contribution; the Client package owns presentation joins. The API Proxy retains only `host.describe` and streamed `GET`/`HEAD /api/session.export`. +Simple unary operations live on their natural business Remote owner. The business package owns the Remote signature and Host adaptation; `@deepseek-ai/dsh-api-remotes/client` selects its generated contribution; the Client package owns presentation joins. Connection owns the transport envelope and exact Fetch route registry, and no API Proxy service remains. -| Legacy RPC | Remote destination | Owner and preserved behavior | +| Former API Proxy operation | Destination | Owner and preserved behavior | |---|---|---| | `session.rename` | `sessionTitle/rename` | `SessionTitleService` resolves the Session through the shared lookup policy and returns the title event sequence. | | `command.list`, `command.execute` | `commands/list`, `commands/execute` | `CommandRuntime` preserves Agent lookup, unmatched commands, and caller cancellation. | @@ -31,6 +31,8 @@ Simple unary operations live on their natural business Remote owner. The busines | `skill.list` | `skills/list` | `SessionSkillCatalog` observes the Session and its recorded preset, uses a live Agent only when one already exists, and never activates an Agent for listing. | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` supplies the Session Controller's established Agent lookup to the provider; cold lookup behavior remains unchanged. | | `host.openPath` | `session/openWorkspacePath` | The Session-aware Client resolves relative paths against the known workspace before `SessionController` hands them to the native opener. | +| `host.describe` | `$events` ready frame plus capability queries | API Remotes sends the Host home with generation readiness; Settings and Session controllers report their native-open capabilities when the corresponding page appears. Unused process metadata is not sent. | +| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` registers an exact Connection Fetch route and streams the ZIP without a JSON Remote envelope. | The shared Agent and Session resolver remains the authority for endpoints that accept those objects. It provides the same live reuse, cold restoration, concurrent deduplication, preset setup, persistence failures, and subagent ownership fence that legacy API Proxy calls used. `TypertLookupFailure` preserves resolver-owned RPC errors instead of collapsing them into `internal`. @@ -38,7 +40,7 @@ The native path implementation lives in `@deepseek-ai/dsh-native-command`. Setti ## Browser authentication -Connection authenticates the complete `/api` request before choosing the Typert interceptor or API Proxy fallback. Remote-owned endpoints and retained API Proxy endpoints therefore require the same browser session and Host/Origin checks. +Connection authenticates the complete `/api` request before choosing a Typert endpoint or exact Fetch route. Remote calls and Session-log downloads therefore require the same browser session and Host/Origin checks. ## Verification @@ -48,12 +50,16 @@ Focused Host and Client tests cover Remote calls, lookup and no-activation polic **Keep simple calls in the API Proxy.** Rejected because it preserves duplicate interfaces, schemas, route rows, stubs, and result projections after a business owner exists. -**Move every unary operation.** Rejected because `host.describe` combines deployment facts and Connection readiness, while Session export is a streamed download rather than a unary business method. +**Keep `host.describe`.** Rejected because one bootstrap call coupled Connection readiness to unrelated process and business facts. The generation-ready frame carries the only lifecycle fact needed immediately, and capability-owning pages query their domains when shown. -**Put native opening in one controller.** Rejected because Session, Settings, and the retained Host description consume the same platform operation. A Host utility avoids controller-to-controller imports and duplicated platform logic. +**Publish every business capability in the generation-ready frame.** Rejected because those values have no common update lifecycle. Only the stable Host home belongs to Connection; each business owner answers its own current capability. + +**Represent Session export as a Remote.** Rejected because the browser download manager consumes a streamed HTTP response rather than a JSON result. An exact registered Fetch route keeps ownership in the feature package without adding a second gateway. + +**Put native opening in one controller.** Rejected because Session and Settings select different authorized targets. A Host utility avoids controller-to-controller imports without making the browser authoritative for filesystem targets. ## Consequences -Business owners and Client consumers each define one side of a unary operation, while Connection retains authentication, transport, and response envelopes. Removing the legacy client timeout is the accepted observable transport change; business results, cancellation, lifecycle policy, filtering, and native-path authority remain owned by their existing domains. +Business owners and Client consumers each define one side of a unary operation, while Connection owns authentication, transport, response envelopes, exact Fetch routes, and generation state. Removing the legacy client timeout is the accepted observable transport change; business results, cancellation, lifecycle policy, filtering, and native-path authority remain owned by their existing domains. Generated Remote artifacts and the explicit API Remotes assembly become required whenever a Remote signature or selected package changes. diff --git a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md index 50b3038768..74bca8fd72 100644 --- a/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.zh.md @@ -12,9 +12,9 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 ## 决策 -简单一元操作归属其自然的业务 Remote owner。业务包持有 Remote 签名与 Host 适配;`@deepseek-ai/dsh-api-remotes/client` 选择其生成贡献;Client 包持有呈现联接。API Proxy 只保留 `host.describe` 与流式 `GET`/`HEAD /api/session.export`。 +简单一元操作归属其自然的业务 Remote owner。业务包持有 Remote 签名与 Host 适配;`@deepseek-ai/dsh-api-remotes/client` 选择其生成贡献;Client 包持有呈现联接。Connection 持有传输 envelope 与精确 Fetch 路由注册表,不再存在 API Proxy 服务。 -| 旧 RPC | Remote 目标 | Owner 与保留行为 | +| 原 API Proxy 操作 | 目标 | Owner 与保留行为 | |---|---|---| | `session.rename` | `sessionTitle/rename` | `SessionTitleService` 通过共享 lookup 策略解析 Session,并返回标题事件序号。 | | `command.list`、`command.execute` | `commands/list`、`commands/execute` | `CommandRuntime` 保留 Agent lookup、未匹配命令与调用方取消。 | @@ -31,6 +31,8 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 | `skill.list` | `skills/list` | `SessionSkillCatalog` 观察 Session 及其记录的 preset,仅在 live Agent 已存在时使用它,列表查询绝不激活 Agent。 | | `fileReferences/list` | `fileReferences/list` | `SessionFileReferences` 向 provider 提供 Session Controller 的既有 Agent lookup;冷 lookup 行为保持不变。 | | `host.openPath` | `session/openWorkspacePath` | Session-aware Client 先基于已知 workspace 解析相对路径,再由 `SessionController` 交给原生打开器。 | +| `host.describe` | `$events` ready frame 与 capability 查询 | API Remotes 随 generation readiness 发送 Host home;Settings 与 Session controller 在对应页面显示时报告各自的原生打开能力。不发送无人使用的进程元数据。 | +| `session.export` | `GET`/`HEAD /api/session.export` | `session-log-export` 注册精确的 Connection Fetch 路由,并在没有 JSON Remote envelope 的情况下流式传输 ZIP。 | 共享 Agent 与 Session resolver 仍是接收这些对象的 endpoint 的权威。它提供与旧 API Proxy 调用相同的 live 复用、冷恢复、并发去重、preset setup、持久化失败与 subagent ownership fence。`TypertLookupFailure` 保留 resolver 持有的 RPC error,而不把它们归并为 `internal`。 @@ -38,7 +40,7 @@ Host API Proxy 曾在业务 Service、API Proxy interface、Zod schema、路由 ## 浏览器认证 -Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整的 `/api` 请求。因此 Remote 持有的 endpoint 与保留的 API Proxy endpoint 要求相同的浏览器会话和 Host/Origin 校验。 +Connection 在选择 Typert endpoint 或精确 Fetch 路由前认证完整的 `/api` 请求。因此 Remote 调用与 Session 日志下载要求相同的浏览器会话和 Host/Origin 校验。 ## 验证 @@ -48,12 +50,16 @@ Connection 在选择 Typert interceptor 或 API Proxy fallback 前认证完整 **将简单调用留在 API Proxy。** 否决,因为业务 owner 已存在后,这仍会保留重复的 interface、schema、路由行、stub 与结果投影。 -**迁移每一个一元操作。** 否决,因为 `host.describe` 组合部署事实与 Connection readiness,而 Session export 是流式下载,不是一元业务方法。 +**保留 `host.describe`。** 否决,因为一次 bootstrap 调用会把 Connection readiness 与互不相关的进程和业务事实耦合起来。generation-ready frame 只携带立即需要的生命周期事实,各 capability owner 页面在显示时查询自己的当前能力。 -**把原生打开操作放入某个 controller。** 否决,因为 Session、Settings 与保留的 Host 描述都会消费同一平台操作。Host 工具可以避免 controller 间导入与重复的平台逻辑。 +**在 generation-ready frame 中发布所有业务 capability。** 否决,因为这些值没有共同的更新生命周期。只有稳定的 Host home 属于 Connection;各业务 owner 回答自己的当前 capability。 + +**把 Session export 表示为 Remote。** 否决,因为浏览器下载管理器消费流式 HTTP 响应,而不是 JSON 结果。精确注册的 Fetch 路由让功能包持有该行为,同时不引入第二个 gateway。 + +**把原生打开操作放入某个 controller。** 否决,因为 Session 与 Settings 选择不同的授权目标。Host 工具可以避免 controller 间导入,同时不让浏览器成为文件系统目标的权威。 ## 后果 -业务 owner 与 Client consumer 各自定义一元操作的一侧,而 Connection 继续持有认证、传输与响应 envelope。删除 legacy Client timeout 是已接受的可观察传输变化;业务结果、取消、生命周期策略、过滤与原生路径权限仍由既有领域持有。 +业务 owner 与 Client consumer 各自定义一元操作的一侧,而 Connection 持有认证、传输、响应 envelope、精确 Fetch 路由与 generation 状态。删除 legacy Client timeout 是已接受的可观察传输变化;业务结果、取消、生命周期策略、过滤与原生路径权限仍由既有领域持有。 每当 Remote 签名或所选包发生变化,都必须更新生成的 Remote 产物和显式 API Remotes assembly。 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index bf8265d9a7..a73eb1a62f 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 3d7c1ae262cca410554bcb6b1a8af35686315ba7 -2026-08-18-session-history-and-event-transport.zh.md: cb1e02de580896a6278bb657e2097b823343ad0b +2026-08-18-session-history-and-event-transport.md: 8f26b2977dceeb2085bf270ae603cd21d48157f5 +2026-08-18-session-history-and-event-transport.zh.md: 10edeff16695cac265f2026b300eb206838a53e4 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 3d7c1ae262..8f26b2977d 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -70,11 +70,11 @@ In-process `connection.rpc.open` uses the same logical endpoint semantics while The Gateway-internal `$events` logical stream is the sole generation source for `ConnectionHandle`. It does not depend on whether any business `$on` subscription exists, so connection health does not vary with the number of UI listeners. -The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready' }` with a `clientId`; this frame proves that the current generation can receive increments. +The Host event source installs incremental listeners synchronously before returning its first frame. Gateway then sends `{ type: 'ready', clientId, host: { home } }`; this frame proves that the current generation can receive increments and carries the stable Host path-display fact. -`ConnectionController` waits for `$events` readiness and `host.describe` in parallel. It publishes `connected` only after both complete, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. +`ConnectionController` publishes `connected` only after `$events` readiness, so a Session or Workspace baseline cannot be read before Host incremental listeners are ready. -Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws `hostDescription`, then re-establishes `$events` and `host.describe` after backoff. +Unexpected normal completion of `$events`, a Host error, a malformed opening frame, or a carrier failure ends the current Connection generation. Connection withdraws the generation, then re-establishes `$events` after backoff. Gateway stream generation, Connection generation, and a Session business open epoch are three independent counters: the first identifies physical replacement of one logical stream, the second identifies a Host-availability handshake, and the last prevents an obsolete Session open from writing into current state. @@ -332,7 +332,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re Gateway mux tests pin connection without logical streams, idle residency, configurable Ping/Pong without application messages, initial-failure and disconnect recovery, active-stream carrier failure, cancellation, and no reconnect after disposal. -Connection tests pin missing, duplicate, and withdrawn generation sources; the race between `$events` ready and `host.describe`; and description withdrawal and rebuilding after generation failure. +Connection tests pin missing, duplicate, and withdrawn generation sources, readiness timeout, and generation withdrawal and rebuilding after failure. `RemoteStream` tests pin single consumption, retry reset after opening acceptance, generation-only `restart()`, no retry for terminal errors, and disposal quiescence. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index cb1e02de58..10edeff166 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -70,11 +70,11 @@ Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)向每 Gateway 内部 `$events` logical stream 是 `ConnectionHandle` 唯一的 generation source。它不依赖是否已有业务 `$on` 订阅,因此连接健康状态不会随 UI listener 数量变化。 -Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送带 `clientId` 的 `{ type: 'ready' }`,该帧证明当前 generation 已经能够接收增量。 +Host event source 在返回首帧前同步安装增量 listener。Gateway 随后发送 `{ type: 'ready', clientId, host: { home } }`;该 frame 证明当前 generation 已经能够接收增量,并携带稳定的 Host 路径显示信息。 -`ConnectionController` 并行等待 `$events` ready 与 `host.describe`。两者都完成后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 +`ConnectionController` 只有在 `$events` ready 后才发布 `connected`,所以 Session 或 Workspace baseline 不会在 Host 增量 listener 就绪前开始读取。 -`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回 `hostDescription`,退避后重新建立 `$events` 与 `host.describe`。 +`$events` 正常意外结束、Host 错误、畸形首帧或 carrier 失败都会结束当前 Connection generation。Connection 撤回该 generation,退避后重新建立 `$events`。 Gateway stream、Connection generation 与 Session 业务 open epoch 是三个独立计数:前者表示某条 logical stream 的物理替换,第二个表示 Host 可用性握手,最后一个防止已淘汰的 Session open 写回当前状态。 @@ -332,7 +332,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace Gateway mux 测试固定无 logical stream 时建连、空闲常驻、可配置且不产生应用消息的 Ping/Pong、初始失败与断线重连、活动 stream carrier failure、取消和 dispose 后不再重连。 -Connection 测试固定 generation source 缺失、重复注册、撤回、`$events` ready 与 `host.describe` 的竞争,以及 generation 失败后的 description 撤回和重建。 +Connection 测试固定 generation source 缺失、重复注册、撤回、ready 超时,以及 generation 失败后的撤回和重建。 `RemoteStream` 测试固定单 consumer、opening acceptance 后清零 retry、`restart()` 只替换 generation、terminal error 不重试和 dispose quiescence。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml index 6756a4d842..88e2561815 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.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-24-browser-token-authentication.md -2026-08-24-browser-token-authentication.md: c75f561d9529296ff668791c29453e522f309cc3 -2026-08-24-browser-token-authentication.zh.md: d4a5059619fefda9d9060e9879d10c0a2197f8f3 +2026-08-24-browser-token-authentication.md: cdfed1f250de6def39e386cf9fcd2e536c6c97e4 +2026-08-24-browser-token-authentication.zh.md: 29f3a6c0a2d83ed1c17b9c0bc5988c9472741f3f diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md index c75f561d95..cdfed1f250 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md @@ -24,7 +24,7 @@ The shipped CLI continues to reject `--host 0.0.0.0`. Authentication does not im ## Verification -Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for API Proxy, generic RPC, Typert Remote HTTP, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `host.describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. +Unit coverage pins process-token retention across Connection reloads, one secret load per activation, synchronous verification without credential-provider reads, cookie attributes, HMAC and payload validation, authority and lifetime checks, record deletion taking effect on the next activation, invalid durable records, and cleanup of obsolete token URLs backed by valid cookies. Host transport suites pin uniform 401/403 behavior for generic RPC, Typert Remote HTTP, exact Fetch routes, and WebSocket upgrade paths. The frontend real-composition test boots credentials, Connection, webserver, and static serving through Loader and proves token exchange before index reads while static assets remain public. Packed-worker tests prove portable cookie encoding and worker-local retry for both authentication and trust rejection. A real-CLI test starts `dsh web` twice on one port with a temporary `DSH_HOME`, proves that forged `Host: localhost` is unauthenticated, calls `settings/describe` with the exchanged cookie, observes a new process token, and reuses the old cookie after restart. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md index d4a5059619..29f3a6c0a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md @@ -24,7 +24,7 @@ HMAC 密钥是 `ctx.credentials` 中位于 `client-connection/browser-session` ## 验证 -单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定 API Proxy、通用 RPC、Typert Remote HTTP 和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `host.describe`,观测新的进程令牌,并在重启后复用旧 cookie。 +单元覆盖 Connection 重载时保留进程令牌、每次激活只加载一次密钥、无需读取凭据提供方的同步校验、cookie 属性、HMAC 与 payload 校验、authority 与有效期校验、记录删除在下一次激活时生效、无效持久记录,以及用有效 cookie 清理过时令牌 URL。Host 传输套件固定通用 RPC、Typert Remote HTTP、精确 Fetch 路由和 WebSocket upgrade 路径上一致的 401/403 行为。frontend 真实组合测试经 Loader 启动 credentials、Connection、webserver 与静态服务,证明读取 index 前完成令牌交换,同时静态资产仍公开。打包 worker 测试证明 cookie 编码可移植,并覆盖认证与信任拒绝后的 worker 本地重试。真实 CLI 测试在临时 `DSH_HOME` 上用同一端口两次启动 `dsh web`,证明伪造 `Host: localhost` 仍未认证,以交换所得 cookie 调用 `settings/describe`,观测新的进程令牌,并在重启后复用旧 cookie。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml index 92d2177999..dd1cbe2e66 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.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-13-bounded-cold-blank-verification.md -2026-08-13-bounded-cold-blank-verification.md: bf8d167d742001ce65b3e96713a9603adb19603e -2026-08-13-bounded-cold-blank-verification.zh.md: 1418301b8cb3c1452cbed2bdaf974949126079e6 +2026-08-13-bounded-cold-blank-verification.md: cd50d29f0b5d417077d5d848415607885c39474a +2026-08-13-bounded-cold-blank-verification.zh.md: 851b1fb35126a42623e251bf790dde2029189b36 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md index bf8d167d74..cd50d29f0b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md @@ -12,7 +12,7 @@ The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Sess ## Decision -`dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. +`dsh-api-session-controller` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`. A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its observed size is at most the `coldBlankProbeMaxBytes` eligibility threshold (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and folds exact list metadata from the stored prefix. Files above the threshold, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible. diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md index 1418301b8c..851b1fb351 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.zh.md @@ -12,7 +12,7 @@ Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 N ## Decision -`dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 +`dsh-api-session-controller` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。 冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其观测大小不超过 `coldBlankProbeMaxBytes` 资格阈值(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,从已存前缀折叠精确列表元数据。超过阈值的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index 951eb4a113..e195ff0f4b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 1dc1ffc3e3c0fdb3b7b084dff91a51209ac80458 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: e1163b43c5c2c8ff9577468a0069fa6956f9fab4 +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: cc94357aae24bd0ca20ee73488f14de98ffd8ca7 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 90667c13e2917a77ffd0bcee6386ded690573bcf diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index 1dc1ffc3e3..cc94357aae 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -10,7 +10,7 @@ Before this change, the Web composer accepted only text: `InputBar` received a s This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../archived/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. -The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. +The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [archived GUI layering and RPC protocol decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index e1163b43c5..90667c13e2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -10,7 +10,7 @@ Status: implemented 这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../archived/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 -[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 +[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[已归档的 GUI 分层与 RPC 协议决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml index 1e3ba8b79e..2d0ef48750 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.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-27-web-session-search.md -2026-07-27-web-session-search.md: e467f4802e5a9b4b6bfef6b3f568b33c0b1fa7a6 -2026-07-27-web-session-search.zh.md: d440f0ce56668f34057959135791e9deca097b14 +2026-07-27-web-session-search.md: bb41028ec4732a602ab1570b22b5c51160692ab2 +2026-07-27-web-session-search.zh.md: 9f03c42cfba018aa379ac2de6f78c6cdbfdb2ee0 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md index e467f4802e..bb41028ec4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.md @@ -16,7 +16,7 @@ The host gateway exposes `session.search` through the existing typed RPC stack. [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.md) keeps metadata and content search deliberately separate. Its default copy is English, and its input plus defensive request path remove NUL and cap queries at the request schema's 500 UTF-16 code units without splitting a surrogate pair. A non-blank query immediately computes case-insensitive title and Workspace substring matches from the Session list, starts a 250 ms debounced content request, aborts the preceding request when the query changes, and ignores stale completions. It merges local matches first in recency order with backend-ranked content-only matches, deduplicates by session id, and renders a flat list regardless of the normal grouping mode. Each row shows the title, Workspace, and an available one-line snippet. Selecting a row opens the Session only and preserves the query; it does not navigate to an exact event. -The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives beside the response schema that enforces it in `dsh-host-apiproxy`, and `SessionRuntime.searchResultLimit` re-exposes that constant for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound that the schema's fixed `max` forbids, and would leave the same fact with two homes in the same module. +The result bound is one protocol constant, not per-connection state. `SESSION_SEARCH_RESULT_LIMIT` lives with the request and result types in `@deepseek-ai/dsh-api-session-controller/types`; Session Controller enforces it, and `ClientSessions.searchResultLimit` re-exposes it for presentation plugins. Reaching it from a feature is an explicit widening of the sessions domain: `ISessions` — the face injected as `ctx.sessions`, and therefore what the test runtime's sessions double must implement — declares the search verb next to that bound. The Connection handle does not carry it: a per-connection field would imply a transport-varying or server-negotiated bound and leave the same fact with two owners. Content matching inherits the SQLite backend's normalized literal token/phrase semantics. The shared semantic projection excludes reasoning blocks, so UI search never returns a model's private reasoning as a hit or snippet; the derived-index schema version advances so existing persistent indexes rebuild without the former documents. FTS5 operators are inert data, and this surface adds no typo, fuzzy, prefix, or arbitrary-substring expansion. In particular, the `unicode61` tokenizer may treat an uninterrupted Chinese sequence as one token, so a shorter query such as `搜索` is not guaranteed to match inside `会话搜索功能`. Title and Workspace matching remains ordinary client-side substring matching. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md index d440f0ce56..9f03c42cfb 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-search.zh.md @@ -16,7 +16,7 @@ Web 与 headless 共用的组合会使用 `openAt: first-search` 和内存数据 [`WorkspaceBrowser`](../../../../packages/client/ui-workspace/README.zh.md) 有意将元数据搜索与内容搜索保持独立。其默认界面文案为英文;输入框及防御性请求路径会移除 NUL,将查询限制在请求 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair。非空白查询会立即从会话列表中计算不区分大小写的标题和 Workspace 子串匹配,在 250 ms 防抖后发起内容请求,在查询变化时中止前一请求,并忽略陈旧的完成结果。它先按新近程度排列本地匹配,再合并由后端排序且仅匹配内容的结果,按会话 id 去重;无论常规分组模式如何,最终都渲染为扁平列表。每一行显示标题、Workspace,并在存在时显示一行摘要片段。选择某一行只会打开对应会话,并保留查询条件;不会跳转至确切事件。 -结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 位于 `dsh-host-apiproxy` 中强制执行它的响应 schema 旁边,`SessionRuntime.searchResultLimit` 则把该常量重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明了搜索动作。连接 handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,而 schema 固定的 `max` 恰恰禁止这一点,并且会让同一事实在同一模块内拥有两处归属。 +结果上限是单一协议常量,而非逐连接状态。`SESSION_SEARCH_RESULT_LIMIT` 与请求和结果类型一起位于 `@deepseek-ai/dsh-api-session-controller/types`;Session Controller 强制执行它,`ClientSessions.searchResultLimit` 则把它重新公开给呈现插件。功能包要取用它,必须显式扩展 sessions 域的对外面:`ISessions`(即注入为 `ctx.sessions` 的那个面,也因此是测试运行时的 sessions 替身必须实现的面)在该上限旁声明搜索动作。Connection handle 不携带它:逐连接字段会暗示该上限随传输层变化或由服务端协商,并让同一事实拥有两处归属。 内容匹配沿用 SQLite 后端经过规范化的字面 token/短语语义。共享语义投影会排除推理(reasoning)块,因此 UI 搜索绝不会将模型的私有推理作为命中或 snippet 返回;派生索引的 schema 版本会随之前进,使现有持久化索引重建并移除先前的这些文档。FTS5 运算符只作为数据处理,此搜索界面不提供拼写错误纠正、模糊匹配、前缀匹配或任意子串扩展。特别是,`unicode61` 分词器可能将一段连续中文视作单个 token,因此不保证 `搜索` 之类的较短查询能匹配 `会话搜索功能` 的内部片段。标题与 Workspace 匹配仍采用普通的客户端子串匹配。 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml index 684ebea047..0838d23d51 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.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-27-web-subagent-conversations.md -2026-07-27-web-subagent-conversations.md: c897d345d9749facfb2046104b7a95076cf2df95 -2026-07-27-web-subagent-conversations.zh.md: 0fdfd19b6dac56c275bb2d2306ed492a269e70d2 +2026-07-27-web-subagent-conversations.md: 5ae4c22627a5f39547f1ca7f22bb9794b74e4340 +2026-07-27-web-subagent-conversations.zh.md: 79065872837ff3dd9e22f4be660991e9c540c7c0 diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md index c897d345d9..5ae4c22627 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md @@ -51,7 +51,7 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part ## Host adapter and wire contract -`@deepseek-ai/dsh-host-apiproxy` owns a browser-safe `subagents` domain: +`@deepseek-ai/dsh-subagent` owns the browser-safe generated `subagent` Remote namespace: - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`. - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent. @@ -63,7 +63,7 @@ Viewing persisted history creates no mux subscription by itself. When a follow-u The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents. -The adapter stays in `dsh-host-apiproxy`; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [GUI RPC layering](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). +The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). ## Client object layer and presentation diff --git a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md index 0fdfd19b6d..7906587283 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md @@ -51,7 +51,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 ## 宿主适配器与协议约定 -`@deepseek-ai/dsh-host-apiproxy` 拥有浏览器安全的 `subagents` 域: +`@deepseek-ai/dsh-subagent` 拥有浏览器安全的生成 `subagent` Remote 命名空间: - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。 @@ -63,7 +63,7 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。 普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。 -适配器仍位于 `dsh-host-apiproxy`;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持 [GUI RPC 分层](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)。 +适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。 ## 客户端对象层与呈现 diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml index 89d0986ee9..56a7ab1fb8 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.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-todo-plan-clears-on-next-turn.md -2026-07-28-todo-plan-clears-on-next-turn.md: 55a0d4f307bef5cd04add0baae220ea763772ee9 -2026-07-28-todo-plan-clears-on-next-turn.zh.md: c5fb809694c680951e1f2238d5596e771e8bcec8 +2026-07-28-todo-plan-clears-on-next-turn.md: b8a199840c683bc1ecb79aa8c9c4ea330da5e474 +2026-07-28-todo-plan-clears-on-next-turn.zh.md: bbe6fd35b5965c19864e3d181a845993e842132d diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md index 55a0d4f307..b8a199840c 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md @@ -14,7 +14,7 @@ The standing plan is the latest `todo/write` that is not followed by a later `tu ### Host projection (web) -`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Carriers (`dsh-host-apiproxy`) serve that value on the history tail `projections` block and push `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots. +`dsh-tool-todo`'s `todos` projection unit folds the rule: `apply` takes the whole list from each `todo/write` and returns `null` on each `turn/start` (`stateVersion` 2). Session Controller serves that value on the history tail `projections` block and pushes `session/projection` frames; the web dock reads it through `useProjection('todos')`. The keyless fixture mirrors the same fold for assembled snapshots. ### TUI live path diff --git a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md index c5fb809694..bbe6fd35b5 100644 --- a/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 宿主投影(web) -`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。载体(`dsh-host-apiproxy`)在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 +`dsh-tool-todo` 的 `todos` 投影单元折叠该规则:`apply` 从每个 `todo/write` 取完整列表,并在每个 `turn/start` 返回 `null`(`stateVersion` 2)。Session Controller 在历史记录尾部的 `projections` 块中提供该值,并以 `session/projection` 帧推送;Web dock 经 `useProjection('todos')` 读取。无密钥 fixture(测试前置数据)镜像同一折叠,供组装后的快照使用。 ### TUI 实时路径 diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml index fa703bf41d..85a24356aa 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.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-tool-call-file-open-in-os.md -2026-07-28-tool-call-file-open-in-os.md: a8d5bd116b3f4cf1434d44b643dad3d883763a82 -2026-07-28-tool-call-file-open-in-os.zh.md: b486ec0972356419c2df5abbc148dc57a4586920 +2026-07-28-tool-call-file-open-in-os.md: 4655af04243b28284cf46c058a472ee658d9a035 +2026-07-28-tool-call-file-open-in-os.zh.md: 769aeaeaa74af54aef60521505a8ed107500fa8a diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md index a8d5bd116b..4655af0424 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.md @@ -12,7 +12,7 @@ Chat tool rows treated the whole summary line as a click target that opened the File-tool path summaries (`read` / `write` / `edit` args carrying `path` or `file_path`) render as links underlined at rest with a pointer cursor. Clicking the path calls `session/openWorkspacePath` through the chat view's `openFile` injection; the chat view resolves relative paths against the addressed Session's cwd when it is known. File-link rows disable args expand (leading icon is inert); whole-row click, row hover fill, and the click-to-open-details gesture are removed from tool rows (including bash and todo registrations). The details panel and its inject surface remain for programmatic selection; rows no longer drive them. -`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `host.describe.canOpenPath` is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. +`session/openWorkspacePath` uses the authenticated Remote carrier, while the product UI offers the gesture only on a loopback page whose `session/canOpenWorkspacePath` result is true. Platform adapters open without a shell: `open` on macOS, PowerShell `Invoke-Item` on Windows, and `xdg-open` on desktop Linux; browser-renderable documents prefer the named default browser on macOS and desktop Linux. WSL is a separate host shape despite Node reporting `linux`: the adapter recognizes its environment or Microsoft kernel release, translates the Linux path with `wslpath -w`, and passes the resulting Windows/UNC path to the same PowerShell handoff. The opener's platform facts and command runner are injectable for tests. URL-only read args (`web_fetch`) are not file links. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md index b486ec0972..769aeaeaa7 100644 --- a/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-tool-call-file-open-in-os.zh.md @@ -12,7 +12,7 @@ Status: implemented 文件工具的路径摘要(`read`/`write`/`edit` 参数中的 `path` 或 `file_path`)渲染为静止状态下即带下划线的链接,并使用 pointer 光标。点击路径会经聊天视图的 `openFile` injection 调用 `session/openWorkspacePath`;聊天视图会在目标 Session 的 cwd 已知时据此解析相对路径。带文件链接的行关闭参数展开(左侧图标不可点);工具行(含 bash 与 todo 注册)去掉整行点击、整行悬停底色,以及点击打开 details 的手势。details 面板及其 inject 面仍保留供程序化选择;工具行不再驱动它们。 -`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `host.describe.canOpenPath` 为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 +`session/openWorkspacePath` 使用经过认证的 Remote carrier,而产品 UI 只在 loopback 页面且 `session/canOpenWorkspacePath` 结果为 true 时提供该手势。平台适配器不经 shell 打开:macOS 为 `open`,Windows 为 PowerShell `Invoke-Item`,桌面 Linux 为 `xdg-open`;浏览器可渲染的文档会在 macOS 与桌面 Linux 上优先使用指定的默认浏览器。尽管 Node 将 WSL 报告为 `linux`,WSL 仍是一种独立的宿主形态:适配器根据其环境或 Microsoft 内核 release 识别它,用 `wslpath -w` 转换 Linux 路径,并将所得 Windows/UNC 路径交给同一 PowerShell 交接。打开器的平台信息和命令运行器可在测试中注入。仅含 URL 的 read 参数(`web_fetch`)不是文件链接。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml index aaa3cc3cc8..92a54e0263 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.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-31-permission-default-for-new-sessions.md -2026-07-31-permission-default-for-new-sessions.md: ebf7fe39712d64c18e12b9b26d86201a61ad6cfd -2026-07-31-permission-default-for-new-sessions.zh.md: c0f450c8efca8647e3058fb305724cf0a554cc8d +2026-07-31-permission-default-for-new-sessions.md: b5bb72ee179760e4b58ae5ca1124f9948ed24d5d +2026-07-31-permission-default-for-new-sessions.zh.md: 9383a1b184f610d2995fb3d6982d3f4001729822 diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md index ebf7fe3971..b5bb72ee17 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.md @@ -16,7 +16,7 @@ The service reads the current Settings value synchronously at `session/created`. The existing `/permission` command and `permissions` projection remain the current-session path. The browser plugin now contributes the Permission row to `settings.general.item`, reads the dynamic enum from the redacted Settings descriptor, and writes only `defaultPreset` through a revision-checked `settings.mutate`. The row injects its observable through the slot `hooks` compartment instead of binding a renderer-specific hook, and the Permission service sweeps already-live sessions when it mounts so HMR cannot leave an unpinned session. The ownerless General-settings package contributes no placeholder rows. -ApiProxy explicitly adds `permission` to its Web settings allowlist beside the configurable-provider namespaces. This is a local boundary decision, not a general registration flag or a `local-client` access model: registering another Settings namespace still does not expose it. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. +The Settings Controller exposes the registered `permission` namespace through its redacted Remote view. This is a local presentation decision, not a general registration flag or a `local-client` access model. Permission changes reach the client through forwarded `settings/document-updated` ([forwarded Remote events](../architecture/2026-08-10-remote-event-delivery.md)); they do not announce model topology. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md index c0f450c8ef..9383a1b184 100644 --- a/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-permission-default-for-new-sessions.zh.md @@ -16,7 +16,7 @@ Web「通用」设置页将「权限」显示为禁用的骨架控件,尽管 ` 现有 `/permission` 命令和 `permissions` 投影仍是当前会话的操作路径。浏览器插件现在向 `settings.general.item` 贡献「权限」行,从脱敏后的 Settings 描述符读取动态 enum,并只通过经过 revision 校验的 `settings.mutate` 写入 `defaultPreset`。该行通过 slot 的 `hooks` 格注入 observable,而不是绑定渲染器专用钩子;权限服务挂载时会遍历并固定所有已存活会话,因此 HMR(热模块替换)不会遗留未固定的会话。无归属的「通用」设置包不贡献任何占位行。 -ApiProxy 在可配置提供方 namespace 之外,将 `permission` 显式加入 Web Settings allowlist。这是局部的边界决策,而不是通用注册标志或 `local-client` 访问模型:注册其他 Settings namespace 仍不会将其暴露。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.zh.md)),不会宣告模型拓扑。 +Settings Controller 通过脱敏的 Remote 视图暴露已注册的 `permission` namespace。这是局部的呈现决策,而不是通用注册标志或 `local-client` 访问模型。权限变更通过转发的 `settings/document-updated` 到达客户端([转发的 Remote 事件](../architecture/2026-08-10-remote-event-delivery.zh.md)),不会宣告模型拓扑。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index 37ebb517b5..c8be4143b4 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.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-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: 5ed9aff020f70c7f37452d903cd458697504bf97 -2026-08-07-default-model-follows-the-picker.zh.md: 1a5d025d41a853f8a662a58fc7dc99bbbfb68c49 +2026-08-07-default-model-follows-the-picker.md: b2d9c0fe58d9219fb33f749563d3d1fb10a20a8a +2026-08-07-default-model-follows-the-picker.zh.md: 7d3e5bc924f20e47b4d6a5e609c0e3c984ed6921 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index 5ed9aff020..b2d9c0fe58 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -12,7 +12,7 @@ Reasoning effort makes the persistence shape significant: a model selection with ## Decision -`AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). +`AgentDefaultModelConfig` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and Session Controller Remote creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. @@ -26,7 +26,7 @@ The stored selection does not require catalog membership. A provider route may s ## Consequences -`host.describe` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The gateway does not expose that namespace through its Settings-page allowlist; the model picker is its editor. +`session/modelCatalog` reports the live Agent default. A successful model switch stores an `agent-default-model:` section in `settings.yaml`. The Settings page does not expose that namespace; the model picker is its editor. ## A session that cannot send diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 1a5d025d41..7d3e5bc924 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md))。 +`AgentDefaultModelConfig` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 Session Controller Remote 创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.zh.md))。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;因此,缺少该字段会清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 @@ -26,7 +26,7 @@ Status: implemented ## 影响 -`host.describe` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。网关不通过 Settings 页 allowlist 暴露该 namespace;模型选择器是它的编辑器。 +`session/modelCatalog` 报告当前 Agent 默认值。模型切换成功后,`settings.yaml` 中会存有一个 `agent-default-model:` 分节。Settings 页面不暴露该 namespace;模型选择器是它的编辑器。 ## 无法发送消息的会话 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml index 3d120f1027..c093041c15 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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-10-web-session-log-export.md -2026-08-10-web-session-log-export.md: e64de4ecd564bf585ec857546913828a87ad1279 -2026-08-10-web-session-log-export.zh.md: 2ef377b4bb3c98f1935e67a1619bbcc5ed1789ef +2026-08-10-web-session-log-export.md: 69cc3d9fdfb267242de863c8359994ef25754bb1 +2026-08-10-web-session-log-export.zh.md: 9ab7318a4b89ddbd343739dc730569f4d8f584ba diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md index e64de4ecd5..69cc3d9fdf 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -11,7 +11,7 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Decision - **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API at validated `sessionExportCompressionLevel` 0–9 (default 6), letting deployments trade CPU and latency against archive size; each entry is deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root). At the 64 KiB response byte high-water mark, production waits for consumer pull to restore capacity; fflate's synchronous callback can add at most one bounded input push beyond that queue bound. No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. -- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **Error vocabulary is HTTP-native**: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). Request abort remains cancellation instead of being rewritten as 500; request and response-consumer cancellation converge on the producer signal, which reaches lineage, persistence, and attachment reads and terminates the active compressor. Connection applies the `/api` trust fence before dispatching the exact `GET`/`HEAD /api/session.export` route registered by `session-log-export`. - **The UI just downloads**: browser consumers may issue a bodyless `HEAD` preflight for preparation errors, then hand the GET endpoint to the browser's native download manager, so JavaScript never buffers the ZIP. The `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The current Header and `/export` consumers are defined by the [session-log export package contract](../../../../packages/session-query/session-log-export/README.md). @@ -25,6 +25,6 @@ The Trajectory view had no way to hand a debugging artifact to a human: the raw ## Consequences - Export fidelity: immediately before reading each live root or descendant, the exporter crosses the authoritative `SessionStore.flush` durability barrier; every exported file is byte-identical to that resulting durable artifact. A live session may append again after its read, so the archive is a per-session read-boundary snapshot rather than one atomic tree snapshot. The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. -- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- `supportsRawArtifacts` explicitly separates backend capability from session absence: unsupported backends such as SQLite report `false` and the concrete `readRaw` default rejects, while the JSONL override reports `true`, owns physical decoding, and reserves `undefined` for an absent artifact. `session-log-export` registers one exact Host-only Fetch route with Connection; no Remote descriptor or JSON envelope represents the streamed response. - Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button. - Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md index 2ef377b4bb..9ab7318a4b 100644 --- a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -11,7 +11,7 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 决策 - **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧使用 fflate 流式 `Zip`/`ZipDeflate` API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本)。到达 64 KiB 响应字节高水位后,生产会等待 Consumer pull 恢复容量;fflate 的同步回调最多只会在该队列界限外再增加一次有界输入 push。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 -- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 Consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,后端不提供每会话原始工件 → 501,根会话缺失 → 404(三者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。请求中止会保持取消语义而不会改写成 500;请求取消与响应 consumer 取消汇合到生产者 signal,该 signal 会传到血缘、持久化与附件读取,并终止活跃压缩器。Connection 在分发 `session-log-export` 注册的精确 `GET`/`HEAD /api/session.export` 路由前应用 `/api` 信任围栏。 - **UI 只负责下载**:浏览器 Consumer 可以先发出不读取 body 的 `HEAD` 预检以取得准备阶段错误,再把 GET 端点交给浏览器原生下载管理器,因此 JavaScript 不会缓冲 ZIP。早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不包含任何归档实现。 - 当前 Header 与 `/export` Consumer 由 [Session 日志导出包约定](../../../../packages/session-query/session-log-export/README.zh.md)定义。 @@ -25,6 +25,6 @@ Trajectory 视图没有任何方式把调试工件交到人手里:原始会话 ## 后果 - 导出保真度:读取每个实时根会话或后代前,导出器会通过权威的 `SessionStore.flush` 持久性屏障;每个导出文件都与由此得到的持久化工件逐字节一致。实时会话可能在自身读取后再次追加,因此归档是按会话读取边界形成的快照,而不是整棵树的原子快照。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 -- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- `supportsRawArtifacts` 明确区分后端能力与会话缺失:SQLite 等不支持的后端报告 `false`,具体 `readRaw` 默认会拒绝;JSONL 覆写则报告 `true`、自持物理解码,并只用 `undefined` 表示工件缺失。`session-log-export` 向 Connection 注册一个精确的 Host-only Fetch 路由;流式响应不使用 Remote descriptor 或 JSON envelope 表示。 - fixture 模式(无宿主)对导出应答 404,浏览器会将其报告为下载失败;navigation-panes golden 快照包含「导出」按钮。 - 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml index a43189052c..8620a0e642 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md -2026-07-22-tsconfig-solution-root-two-aggregates.md: f12f2e7c4b46eacb376ff443ad9080fcd367490f -2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 9c1fc006e7d509fef8e90fc113849f4ef14c2184 +2026-07-22-tsconfig-solution-root-two-aggregates.md: 714f7c4b6ac9839ab51c8453f970346a6f18f17e +2026-07-22-tsconfig-solution-root-two-aggregates.zh.md: 53fe3c4342b78cc90b537fdb5c2dbb29387fbe85 diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md index f12f2e7c4b..714f7c4b6a 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md @@ -6,7 +6,7 @@ English | [中文](2026-07-22-tsconfig-solution-root-two-aggregates.zh.md) ## Problem -The GUI split introduced a second aggregate program (`tsconfig.client.json`, [layering RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: +The GUI split introduced a second aggregate program (`tsconfig.client.json`, [archived layering RFC](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)) while the root `tsconfig.json` kept doubling as the host aggregate, and `tsconfig.build.json` remained a third, hand-maintained full emit graph. That triple bookkeeping produced four concrete asymmetries: - The typecheck and build references lists drifted apart (`packages/goal/command-goal` was in the typecheck graph but missing from the build graph). - The lefthook pre-push hook ran `tsc -b tsconfig.json` only, so client-side type breakage passed the local checkpoint and surfaced in CI. diff --git a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md index 9c1fc006e7..53fe3c4342 100644 --- a/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[分层 RFC](../architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: +GUI 拆分引入了第二个聚合 program(`tsconfig.client.json`,见[已归档的分层 RFC](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)),根 `tsconfig.json` 则继续兼任宿主侧聚合,`tsconfig.build.json` 还是第三份手工维护的全量 emit 图。三处账本并行,造成四个具体的不对称: - 类型检查与构建的 references 列表逐渐脱节(`packages/goal/command-goal` 在类型检查图里,构建图里却没有)。 - lefthook 的 pre-push 钩子只运行 `tsc -b tsconfig.json`,客户端侧的类型破坏因此通过本地检查点,直到 CI 才暴露。 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml index 9ad937a5df..f054e63897 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md -2026-08-08-api-remotes-generated-contract-build.md: 2f5da20c947e60a3c76514631d1c64c38e07b4cd -2026-08-08-api-remotes-generated-contract-build.zh.md: 92d840765a4297d76dfdbab851869c90ac8e6609 +2026-08-08-api-remotes-generated-contract-build.md: 319b0205aacfe1c5d81ca5093199cdd1818a8132 +2026-08-08-api-remotes-generated-contract-build.zh.md: 21ed4593f01cb85f54dc1c75f3b0f269462c0fa0 diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md index 2f5da20c94..319b0205aa 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate and `host/apiproxy` reference `api/remotes/tsconfig.host.json`, while the root Client aggregate and `client/ui-goal` reference `api/remotes/tsconfig.client.json`. `ui-goal` itself remains an ordinary single Client project. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. +The package-root `tsconfig.json` is a solution that only references the two concrete projects; it enters neither aggregate nor any direct consumer's dependency graph. The root Host aggregate references `api/remotes/tsconfig.host.json`, while the root Client aggregate and direct Client consumers reference `api/remotes/tsconfig.client.json`. `session-log-export` uses the same solution-and-leaves structure to keep its Node archive implementation out of its browser controller. The workspace constraints gate walks the reachable Project Reference graph and rejects any face-declared project that references a split package's solution root or opposite leaf; targets with only `tsconfig.json` remain valid from either face. The two projects use disjoint `files` and separate `.tsbuildinfo` files, so they can share `lib/types` without emitting any source file twice. If both sides later need a shared implementation, move that implementation into a neutral package instead of giving the same source to two emitting projects. diff --git a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md index 92d840765a..21ed4593f0 100644 --- a/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.zh.md @@ -43,7 +43,7 @@ packages/api/remotes/ └─ index.ts ~~~ -包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 与 `host/apiproxy` 引用 `api/remotes/tsconfig.host.json`;根 Client aggregate 与 `client/ui-goal` 引用 `api/remotes/tsconfig.client.json`。`ui-goal` 本身仍是普通的单一 Client project。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 +包根 `tsconfig.json` 是只引用两个具体 project 的 solution,不进入任何 aggregate 或直接消费方的依赖图。根 Host aggregate 引用 `api/remotes/tsconfig.host.json`,根 Client aggregate 与直接 Client 消费方引用 `api/remotes/tsconfig.client.json`。`session-log-export` 使用相同的 solution 与 leaf 结构,让 Node archive 实现不进入浏览器 controller。workspace constraints 门禁遍历可达的 Project Reference 图;凡已声明 face 的 project 引用了拆分包的 solution 根或另一侧 leaf,门禁都会拒绝,而只有 `tsconfig.json` 的目标仍可由任一 face 引用。 两个 project 使用互不重叠的 `files` 和不同的 `.tsbuildinfo`,因此可以共享 `lib/types` 而不重复发射任何源码。若未来需要两侧共用一份实现,应把实现移入中立 package,不能把同一源码同时交给两个 emitting project。 diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml index 2b17c1d803..93a1ed623e 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.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/simplification/2026-08-08-copy-only-preset-authoring.md -2026-08-08-copy-only-preset-authoring.md: 54d317a3de236bf2e191424411c25291c52c7edd -2026-08-08-copy-only-preset-authoring.zh.md: ea449a7b73a0b5a7111ff924b6e47e6323bb58f1 +2026-08-08-copy-only-preset-authoring.md: e6ac66a7a3208b495333166d382e669d13633f51 +2026-08-08-copy-only-preset-authoring.zh.md: da25dcab0374126caea375f5312e063354456d3f diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md index 54d317a3de..e6ac66a7a3 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.md @@ -10,7 +10,7 @@ The agent-preset settings page carried a web YAML editor: `agentPreset.write` ac ## Decision -Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `settings/openAgentPresetDirectory { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`; `host.describe.canOpenPath` gates the row, and Settings Controller's `nativeOpen` pins server behavior where platform detection would mislead). +Authoring is a host-side copy, and files are the editor. `agentPreset.write` became `agentPreset.copy { from, agentPreset, name? }`: two ids the host resolves against its own roots plus an optional display name, whole-directory `cp` (symlinks dereferenced, modes re-tightened to owner-only with owner-execute kept), metadata rewritten to keep the source's description but never its name or `order`. The page becomes: read-only viewer over shipped compositions, copy dialog as the only create entry (no blank "new preset" — writing YAML from nothing is not a thing people do), delete for custom rows, and a location action that leads to the files — `settings/openAgentPresetDirectory { agentPreset }` resolves the directory host-side and opens it natively, or answers `{ opened: false, path }` for the row to show as text where the deployment has no desktop (`hasDocument` on `list`; `settings/canOpenAgentPresetDirectory` gates the row, and Settings Controller's `nativeOpen` pins server behavior where platform detection would mislead). ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md index ea449a7b73..da25dcab03 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-copy-only-preset-authoring.zh.md @@ -10,7 +10,7 @@ agent-preset 设置页带着一个网页 YAML 编辑器:`agentPreset.write` ## 决策 -创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称与 `order`。页面变为:随附组装的只读查看器、作为唯一创建入口的复制对话框(不再有空白「新建预设」——从零手写 YAML 不是人会做的事)、自定义行的删除,以及通向文件的位置操作——`settings/openAgentPresetDirectory { agentPreset }` 在 Host 侧解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示(`list` 上的 `hasDocument`;`host.describe.canOpenPath` 控制该行是否显示,Settings Controller 的 `nativeOpen` 则在平台探测可能误判时固定服务端行为)。 +创作改为宿主端复制,文件就是编辑器。`agentPreset.write` 变为 `agentPreset.copy { from, agentPreset, name? }`:两个由宿主对照自身根目录解析的 id 加一个可选显示名,整目录 `cp`(符号链接解引用,权限收紧为仅属主并保留属主执行位),元数据重写为保留来源描述、但绝不保留其名称或 `order`。页面包含随附组装的只读查看器、作为唯一创建入口的复制对话框(不提供空白「新建预设」)、自定义行的删除,以及通向文件的位置操作。`settings/openAgentPresetDirectory { agentPreset }` 在 Host 侧解析目录并原生打开,部署没有桌面时回答 `{ opened: false, path }` 供该行以文本形式展示;`settings/canOpenAgentPresetDirectory` 控制该行是否显示,Settings Controller 的 `nativeOpen` 则在平台探测可能误判时固定服务端行为。 ## 后果 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml index d51608534c..089e243f05 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.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/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md -2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 14873d3296461357fe6582386f394bc1a5bd9483 -2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: 4bdac0712972eb3cb85c8e721b39146995401385 +2026-07-26-dependency-swaps-rejected-by-nih-audit.md: 93a40d2751dfb24778ed77815480effc7d57a1fc +2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md: c703a53bece7a53c7dc92a17fcccdbd29e9efb49 diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md index 14873d3296..93a40d2751 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.md @@ -16,7 +16,7 @@ Adopt the following dependency swaps. Rejected — per-item evidence below; a fu - **`vscode-jsonrpc` for LSP base-protocol framing/correlation** (`lsp-stdio`): the swappable core is ~255 of ~1,800 src lines; the package cannot express the configured `maxMessageBytes` incoming-size bound (restoring it means rebuilding the deleted framing), inverts the cancel-grace teardown semantics (`raceAbort` rejects immediately then tears down; vscode-jsonrpc keeps the promise pending), errors on pre-header stdout banners real servers emit, and is CJS in an ESM-everywhere repo. The [LSP seam note](../../implemented/architecture/2026-07-15-lsp-capability-seam.md) assigns JSON-RPC ownership to `dsh-lsp-stdio`; this audit is the explicit on-record weighing of the dependency it lacked. - **`vscode-languageserver-types` for lsp-stdio's wire-type subset**: ~80 type lines and ~45 guard lines, but upstream guards differ in both directions (accept `uri: undefined` the repo must reject; require `targetRange` the repo tolerates absent), and the initialize-result shapes live in `vscode-languageserver-protocol`, dragging `vscode-jsonrpc` in as a runtime dep — ~1 MB for 80 spec-exact lines. -- **`json-rpc-2.0` for `dsh-sdk-jsonrpc-server`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [GUI RPC note](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). +- **`json-rpc-2.0` for `dsh-sdk-jsonrpc-server`**: deletable correlation/dispatch is real (~100–130 lines) but the NDJSON wire must stay bit-identical for the hand-rolled Python SDK client, the package is single-maintainer, and the [archived GUI RPC note](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) already treats this package as a frozen narrow surface. `vscode-jsonrpc` is a worse fit still (Content-Length framing, cancellation vocabulary the protocol lacks). - **`jsonrpcclient` for the Python SDK client**: v4 builds/parses messages only — ~20 lines — while the 500 lines that matter (subprocess lifecycle, threaded reader, id correlation, bidirectional server-role responses) stay; the library is in low-maintenance mode. - **`eventsource-parser` for apiproxy's `readSse`**: only ~15 lines of framing are deletable, both wire ends are in-repo so spec conformance is moot, and it would add a dep to a browser-safe package. (Contrast with the [archived llm-deepseek dependency decision](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md), where a real provider sits across the wire.) diff --git a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md index 4bdac07129..c703a53bec 100644 --- a/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-26-dependency-swaps-rejected-by-nih-audit.zh.md @@ -16,7 +16,7 @@ Status: rejected — 下列每一项替换在证据上都未达到净简化门 - **以 `vscode-jsonrpc` 承担 LSP 基础协议的分帧/关联**(`lsp-stdio`):可替换的核心只占 src 约 1,800 行中的约 255 行;该包无法表达已配置的 `maxMessageBytes` 入站大小上限(要恢复它就得重建被删掉的分帧代码),反转了取消宽限期的拆除语义(`raceAbort` 立即 reject 再拆除;vscode-jsonrpc 让 promise 保持挂起),会在真实服务器输出的 header 前 stdout 横幅上报错,而且在这个全面采用 ESM 的仓库里它是 CJS。[LSP seam 决策](../../implemented/architecture/2026-07-15-lsp-capability-seam.zh.md)把 JSON-RPC 的所有权划给 `dsh-lsp-stdio`;本次审计正是对该决策当时缺失的这项依赖权衡的明文记录。 - **以 `vscode-languageserver-types` 承担 lsp-stdio 的协议类型子集**:约 80 行类型加约 45 行守卫,但上游守卫在两个方向上都与本仓库不一致(接受本仓库必须拒绝的 `uri: undefined`;强制要求本仓库容忍缺失的 `targetRange`),而且 initialize 结果的形状住在 `vscode-languageserver-protocol` 里,会把 `vscode-jsonrpc` 拖成运行时依赖——为 80 行严格贴合规范的代码付出约 1 MB。 -- **以 `json-rpc-2.0` 替换 `dsh-sdk-jsonrpc-server`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且 [GUI RPC 决策](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 +- **以 `json-rpc-2.0` 替换 `dsh-sdk-jsonrpc-server`**:可删除的关联/分发代码确实存在(约 100–130 行),但 NDJSON 协议格式(wire format)必须与手写的 Python SDK 客户端逐位一致,该包只有单一维护者,且[已归档的 GUI RPC 决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)已把这个包当作冻结的窄接口面对待。`vscode-jsonrpc` 更不合适(Content-Length 分帧、该协议并不具备的取消词汇)。 - **以 `jsonrpcclient` 承担 Python SDK 客户端**:v4 只做消息的构造/解析——约 20 行——而真正要紧的 500 行(子进程生命周期、线程化读取器、id 关联、双向的服务端角色应答)全都保留;该库处于低维护模式。 - **以 `eventsource-parser` 替换 apiproxy 的 `readSse`**:可删除的分帧只有约 15 行,线路两端都在仓库内,规范符合性无关紧要,而且这会给一个浏览器安全的包添加依赖。(对比[已归档的 llm-deepseek 依赖决策](../../archived/simplification/2026-07-26-eventsource-parser-for-deepseek-sse.md):那里线路对面是真实的提供方。) diff --git a/AGENTS.md b/AGENTS.md index eeb286ccaa..a92ef8a9cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -118,7 +118,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Opaque cross-boundary ids are branded** (`Branded` from `dsh-brand`), never bare `string`. - **Trust TypeScript at typed same-process boundaries.** Do not add runtime validation, fallback behavior, or hostile-input tests solely for values the static interface requires; validate at parser/config, queued, model/tool JSON, durable/file, worker, process, and wire boundaries. - **Source plane vs artifact plane, never mixed.** Static gates and tests resolve workspace imports through tsconfig `paths` to `src` and pass on a clean tree; gates consuming built `lib/` declare that dependency ([layout](docs/development.md#typescript-project-layout)). -- **Keep compiler faces explicit.** Each package uses one aggregate except `api/remotes`; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). +- **Keep compiler faces explicit.** A package with both Host and Client programs exposes face-specific leaf configs and a solution-only root; repo-wide programs seed a face config, never the root solution ([layout](docs/development.md#typescript-project-layout)). - **An empty `catch` names what it swallows** and why nothing else can reach it; keep the `try` to one statement. - **Keep comments local.** Do not restate code, explain distant behavior unless locally required, or expand unrelated comments ([rationale](.agents/notes/implemented/process/2026-08-09-concrete-prose-names-actors-and-recorded-facts.md)). - **Prefer symmetry for parallel values**; unexplained asymmetry usually signals a missed extraction. diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 4bcc0e2b0b..b4c3dc3f3c 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: dcb5f7fd36ba2f5db3e4ce65abc23f266872c4d3 -README.zh.md: bc87059883f641d850e18516b86f4bb089d8158e +README.md: 03ec7c3a14b35f20b748a1534ae7f98861cc6a49 +README.zh.md: d8655832656d73d6b7193be918da422648c50e84 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index dcb5f7fd36..03ec7c3a14 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -30,7 +30,7 @@ The shipped apps own these command lines: | `sdk-minimal` | no options; stdio carries the same JSON-RPC protocol | | `acp` | no options; stdio carries Agent Client Protocol | -A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client, and opens no listening port. +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It streams non-empty provider reasoning deltas to stderr under a `dsh: reasoning:` heading, prints only the final text on stdout, and exits 0 for `completed`, else 1; a successful response with no reasoning leaves stderr empty. An invocation with no task is a usage error from that app. The shipped headless profile mounts no browser Connection, HTTP server, Web runtime, or browser client, and opens no listening port. Inspect the composed tree without booting it: diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index bc87059883..d865583265 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -30,7 +30,7 @@ | `sdk-minimal` | 无选项;stdio 携带相同的 JSON-RPC 协议 | | `acp` | 无选项;stdio 携带 Agent Client Protocol | -一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。 +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对会话执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 `dsh: reasoning:` 标题下将非空的提供方推理分片流式写入 stderr,只在 stdout 打印最终文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出;没有推理内容的成功响应会保持 stderr 为空。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载浏览器 Connection、HTTP 服务器、Web 运行时或浏览器客户端,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 3c260eab76..ef09bb3c70 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/web/tests/README.md -README.md: 2104d9422cfbbcbc7ffc4b12e491c0a62daa3b9d -README.zh.md: 4dfa5b2f61c757e481d9b8e012b37a5e71d75093 +README.md: e5b97f0b7527da01ce7c926360145fee49e168f0 +README.zh.md: f360c2b487bbf5b1a1c81492e5b4c2d4eaa8d749 diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index 2104d9422c..e5b97f0b75 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -11,8 +11,8 @@ the deliberate composition divergences from `dsh web` — are documented in ## These are Host-face tests They type-check in the root `tsconfig.host.json`, not in the Client aggregate, -because they read Host services directly: `ctx.apiProxy`, the Host -`SessionStore`, `ctx.sessionProjectionCache`. Driving a browser at runtime does +because they read Host services directly: `ctx.connection`, the Host +`SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 4dfa5b2f61..f360c2b487 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -10,7 +10,7 @@ ## 这些是 Host 面的测试 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 -Host 服务:`ctx.apiProxy`、Host 侧 `SessionStore`、`ctx.sessionProjectionCache`。运行时驱动 +Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动 浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 8f2b62ad29..a4899f5e57 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -1,5 +1,5 @@ // Web e2e scenario: fresh round trip. A real chromium types a prompt into the -// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo +// real composer; the wire, Remote gateway, agent loop, and the REAL bash tool (echo // in the temp workspace) all run; the model adapter is dsh-llm-replay (keyless) // or the live adapter (record). Drive steps run in every mode and wait only // on generic completion (whenTurnSettled — never model-content selectors, so diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 07858f39c4..c5feee2c91 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 86c43ccc5719107ded1e4004d6fabbc32b760520 -api-gateway.zh.md: 6fa170b84129eb96a70d0831158a9f9c43db256c +api-gateway.md: 43b00bcff3da0adb1d53ac25534b65366bc42611 +api-gateway.zh.md: bb2711220fd75ef0119e1188c22a0af5ad4169a8 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 86c43ccc57..43b00bcff3 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -118,13 +118,13 @@ Strict analysis requires a Remote to be a public, non-static instance method wit ## Runtime invocation -Remote and API Proxy share the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. +Remote calls use the Connection's `/api` route. The Client Remote calls `connection.rpc.call('/api', '/', { args }, signal)`; the HTTP carrier maps this to `POST /api//`, with a payload containing only a named `args` object. -The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler in interceptor order. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; unclaimed requests fall back to the existing API Proxy. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. +The Connection performs the unified trust check for `/api` before the HTTP bridge, then dispatches inside the shared FetchHandler. The Typert Gateway claims only two-segment endpoints that have a strict descriptor or active SRC marker; feature-owned exact Fetch routes handle non-JSON responses, and other requests return 404. The Connection owns transport, RPC ids, response envelopes, and request cancellation, while the Gateway owns only the Remote data protocol and business dispatch. Replacing the Connection carrier does not require changes to Remote descriptors or the Client programming interface. For every call, the Gateway resolves the descriptor and live service from the current registries instead of caching business objects. It requires the fields in `args` to match the descriptor exactly, validates wire values with codecs, resolves objects or receivers through registered lookup or Context providers, invokes the service method targeted by the binding, and validates the return value. A missing provider, unknown identity, binding mismatch, missing or extra argument, schema failure, or missing method fails before entering or after leaving business code. -The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. The Web API Proxy supplies its Agent defaults and scope setup, then consumes the same resolver for legacy methods. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. +The lookup provider's `register()` supplies both the stable declaration and the default resolver; `configure()` supplies a resolver owned by Host composition that may execute asynchronously and is scoped to an effect lifetime. Configuration may precede provider mounting; without a provider, invocation still fails with `lookup-unavailable`, and unloading the configuration restores the provider's default policy. The Session Controller owns the standard `agentFor()` semantics for `agent` and `session`: it reuses a live Agent, automatically resumes ordinary cold sessions, deduplicates concurrent resumes, and rejects identities owned by subagent routing; the `session` lookup returns that Agent's Session. Resume failures and ownership fences pass through unchanged as existing RPC errors rather than being collapsed into the Gateway's `internal` error. Unloading a Client contribution removes its descriptors and concrete methods together, aborts its in-flight calls, and makes stale method handles retained by external code reject further calls. A strict endpoint withdrawn on the Host also does not degrade to SRC inference, preventing a hot unload from silently weakening validation. @@ -159,6 +159,6 @@ The running Client watcher consumes these generated files when it rebundles. If Remote handles only unary method calls with one request and one result. Session event streams, pagination, incremental reduce, projection, and entity substreams require a separate data protocol and registration model; even when they reuse the Connection, they must not masquerade as Remote methods or enter invocation descriptors. -The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. The API Proxy at `packages/host/apiproxy` handles endpoints without Remote descriptors. +The API layers are organized as `remotes → gateway → connection → webserver`. The BFF and Typert RPC layers live under `packages/api`; Connection and WebServer live at `packages/client/connection` and `packages/host/webserver`. A feature that needs a streamed or browser-native response registers an exact Connection Fetch route instead of defining a Remote method. Lookup policy is configured per key, so all `agent` or `session` parameters share the cold-resume behavior. Accepting live objects only would require an explicit per-parameter or per-endpoint policy, which does not exist; the business method must not guess whether the object came from restoration. diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 6fa170b841..bb2711220f 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -118,13 +118,13 @@ Remote Client 声明中的参数名来自 wire 字段,参数和返回类型则 ## 运行时调用 -Remote 与 API Proxy 共用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 +Remote 调用使用 Connection 的 `/api` 路由。Client Remote 调用 `connection.rpc.call('/api', '/', { args }, signal)`;HTTP carrier 对应 `POST /api//`,payload 只包含一个具名 `args` 对象。 -Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内按 interceptor 顺序分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;未认领的请求回退到既有 API Proxy。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 +Connection 在 HTTP bridge 之前执行 `/api` 的统一信任检查,再在共享 FetchHandler 内分发。Typert Gateway 只认领存在严格描述符或活跃 SRC marker 的两段式 endpoint;功能自有的精确 Fetch 路由处理非 JSON 响应,其他请求返回 404。Connection 拥有传输、RPC id、响应 envelope 和请求取消,Gateway 只拥有 Remote 数据协议和业务分发。替换 Connection carrier 不要求改变 Remote 描述符或 Client 编程接口。 Gateway 每次调用都从当前注册表解析描述符和实时服务,不缓存业务对象。它要求 `args` 的字段集合与描述符完全一致,先用 codec 校验 wire 值,再通过注册的 lookup 或 Context 提供方解析对象或接收者,最后调用 binding 指向的服务方法并校验返回值。缺少提供方、identity 未命中、binding 不一致、参数缺失或多余、schema 失败和方法不存在都会在进入业务代码前或离开业务代码后失败。 -lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。Web API Proxy 提供 Agent 默认值与 scope 设置,再让旧方法使用同一个 resolver。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 +lookup 提供方的 `register()` 同时提供稳定声明和默认 resolver;`configure()` 提供由 Host 组合拥有、可异步执行且受 effect 生命周期约束的 resolver。配置可以先于提供方挂载;没有提供方时调用仍以 `lookup-unavailable` 失败,配置卸载后则恢复提供方默认策略。Session Controller 负责 `agent` 与 `session` 的标准 `agentFor()` 语义:复用 live Agent,自动恢复普通冷会话,对并发恢复去重,并拒绝由 subagent routing 拥有的 identity;`session` lookup 返回该 Agent 的 Session。恢复失败和 ownership fence 通过既有 RPC error 原样返回,不折叠为 Gateway 的 `internal` 错误。 Client 卸载一个贡献时会一起移除描述符和具体方法,中止其进行中的调用,并使外部仍持有的陈旧方法句柄拒绝继续调用。Host 上已经注册过的严格 endpoint 被撤回后也不会降级到 SRC 推断,以免热卸载悄然降低校验强度。 @@ -159,6 +159,6 @@ pnpm run build:lib Remote 只处理有单个请求与单个结果的一元方法调用。会话事件流、分页、增量 reduce、projection 和实体子流需要独立的数据协议与注册模型;即使它们复用 Connection,也不应伪装成 Remote 方法或放入调用描述符。 -API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。位于 `packages/host/apiproxy` 的 API Proxy 处理没有 Remote 描述符的 endpoint。 +API 各层按 `remotes → gateway → connection → webserver` 组织。BFF 与 Typert RPC 层位于 `packages/api`;Connection 与 WebServer 位于 `packages/client/connection` 和 `packages/host/webserver`。需要流式或浏览器原生响应的功能注册精确的 Connection Fetch 路由,而不定义 Remote 方法。 lookup 策略按 key 配置,因此所有 `agent` 或 `session` 参数共享冷恢复行为。只接受 live 对象需要显式的逐参数或逐 endpoint 策略,而这种策略并不存在;不能通过业务方法内部猜测对象是否来自恢复。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 4033b29587..97d450238e 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 4e9109294c3b02476af9bf97ecf280b1ea84b128 -capability-seams.zh.md: a4d6dd1b7d4111736d532aa36a4b806aef7d8dc0 +capability-seams.md: 2ff2c3641f163c46ba19616bf35d60df82b3c748 +capability-seams.zh.md: 3cb636d0a997f4c36905f6434d7f2b2d0a22dbd8 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 4e9109294c..2ff2c3641f 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -11,7 +11,6 @@ flowchart LR svc_attachments["ctx.attachments
    Durable binary attachment storage"] pkg_attachment_local["attachment-local"] pkg_api_session_controller["api-session-controller"] - pkg_host_apiproxy["host-apiproxy"] pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] @@ -114,6 +113,7 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
    Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
    Persisted projection cache"] + pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
    Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -168,7 +168,6 @@ flowchart LR pkg_fs_observation_policy["fs-observation-policy"] pkg_compaction["compaction"] svc_compaction["ctx.compaction
    Compaction seam"] - pkg_subagent["subagent"] svc_subagents["ctx.subagents
    Subagent provider and continuation service"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_subagent_fork_in_process["subagent-fork-in-process"] @@ -215,7 +214,6 @@ flowchart LR pkg_lsp["lsp"] svc_lsp["ctx.lsp
    Language-server navigation seam"] pkg_tool_lsp["tool-lsp"] - svc_apiProxy["ctx.apiProxy
    Host API dispatch"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
    Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
    Dynamic Cordis inspect registry"] @@ -257,7 +255,6 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals - pkg_host_apiproxy --> svc_apiProxy pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker @@ -336,20 +333,18 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless - svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent_in_process_driver - svc_apiProxy --> pkg_client_connection svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_attachments --> pkg_api_session_controller - svc_attachments --> pkg_host_apiproxy svc_attachments --> pkg_llm_deepseek svc_attachments --> pkg_llm_pi_ai svc_attachments --> pkg_tool_fs @@ -358,7 +353,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compaction --> pkg_compaction_basic svc_cordisInspect --> pkg_tool_cordis - svc_credentials --> pkg_host_apiproxy + svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -391,8 +386,11 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash - svc_sessionProjectionCache --> pkg_host_apiproxy - svc_sessionProjections --> pkg_host_apiproxy + svc_sessionProjectionCache --> pkg_api_session_controller + svc_sessionProjectionCache --> pkg_session_query + svc_sessionProjectionCache --> pkg_session_reference + svc_sessionProjectionCache --> pkg_subagent + svc_sessionProjections --> pkg_api_session_controller svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference @@ -405,7 +403,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_in_process_driver - svc_settings --> pkg_host_apiproxy + svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai svc_shell --> pkg_hooks_claude_code @@ -465,7 +463,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`host-apiproxy`](../packages/host/apiproxy), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | Plugins prepare independent top-level fields; the official adapter merges them and commits their delivery state after HTTP acceptance. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | @@ -482,9 +480,9 @@ flowchart LR | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the settings controller serves redacted layered descriptors and writes the user layer. | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Owns the default-off settings namespace that Agent-scoped delegation tools sample when composing a new top-level Session. | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the settings controller exposes value-free views and write-only storage. | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | Flows are registered by the plugin that knows how to obtain one credential and keyed by the record they write; the seam owns the conversation and the one-attempt-per-key lifecycle, never the protocol. | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | @@ -501,11 +499,11 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | -| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and api-proxy serves baselines and pushes changed values. | -| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | +| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Layers the default ModelSelection through settings so direct and Host-backed Agent entry points share one state owner. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | Owns one shared E2B SDK handle, remote working directory, and final sandbox disposition so both fundamental E2B providers inhabit the same Linux runtime. | @@ -532,7 +530,6 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | One engine per context, as in bash, with no named-provider registry; the general workflow and fixed Ralph consumers start runs whose agent() calls fan out through ctx.subagents. | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/webhook/webhook) | - | [`webhook-github`](../packages/webhook/webhook-github) | - | Provider adapters dispatch authenticated deliveries; trusted plugins register independent process-local rules, and the runtime turns non-null results into ordinary Workspace-backed Sessions without delivery or completion state. | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | Provider registration and selection plus normalized query execution over exactly four operations; the seam offers no protocol escape hatch, so a backend translates into the normalized request and result. | -| `ctx.apiProxy` | `core` | [`host-apiproxy`](../packages/host/apiproxy) | - | [`client-connection`](../packages/client/connection) | - | The transport-agnostic host gateway face: it dispatches browser API calls, and each open host stream subscribes to the events it forwards rather than being pushed to through a broadcast verb. | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Owns the in-memory definition registry, the vm sandbox for host halves, and the request-run round trip; browser pages reach the same service over the wire through its remote namespace. | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | Registers host inspect providers, mirrors the client provider manifest, and routes client queries through the dynamic Cordis transport. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a4d6dd1b7d..3cb636d0a9 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -13,7 +13,6 @@ flowchart LR svc_attachments["ctx.attachments
    Durable binary attachment storage"] pkg_attachment_local["attachment-local"] pkg_api_session_controller["api-session-controller"] - pkg_host_apiproxy["host-apiproxy"] pkg_tool_fs["tool-fs"] pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_deepseek["llm-deepseek"] @@ -116,6 +115,7 @@ flowchart LR svc_sessionProjections["ctx.sessionProjections
    Session projection units"] pkg_session_projection_cache["session-projection-cache"] svc_sessionProjectionCache["ctx.sessionProjectionCache
    Persisted projection cache"] + pkg_subagent["subagent"] pkg_skill["skill"] svc_skills["ctx.skills
    Skill provider registry"] pkg_skill_badge["skill-badge"] @@ -170,7 +170,6 @@ flowchart LR pkg_fs_observation_policy["fs-observation-policy"] pkg_compaction["compaction"] svc_compaction["ctx.compaction
    Compaction seam"] - pkg_subagent["subagent"] svc_subagents["ctx.subagents
    Subagent provider and continuation service"] pkg_subagent_spawn_in_process["subagent-spawn-in-process"] pkg_subagent_fork_in_process["subagent-fork-in-process"] @@ -217,7 +216,6 @@ flowchart LR pkg_lsp["lsp"] svc_lsp["ctx.lsp
    Language-server navigation seam"] pkg_tool_lsp["tool-lsp"] - svc_apiProxy["ctx.apiProxy
    Host API dispatch"] pkg_cordis_host_runner["cordis-host-runner"] svc_dynamicCordisRunner["ctx.dynamicCordisRunner
    Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
    Dynamic Cordis inspect registry"] @@ -259,7 +257,6 @@ flowchart LR pkg_fs_local --> svc_fs pkg_fs_sandbox --> svc_fs pkg_goal --> svc_goals - pkg_host_apiproxy --> svc_apiProxy pkg_host_directory_picker --> svc_directoryPicker pkg_host_directory_picker_browse --> svc_directoryPicker pkg_host_directory_picker_native --> svc_directoryPicker @@ -338,20 +335,18 @@ flowchart LR pkg_workflow --> svc_workflowEngine pkg_workflow_worker_thread --> svc_workflowEngine pkg_workspace --> svc_workspaceRegistry + svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless - svc_agentDefaultModel --> pkg_host_apiproxy svc_agentLoop --> pkg_agent_spine_demo svc_agentTeams --> pkg_experimental_client_ui_agent_team svc_agentTeams --> pkg_experimental_tool_agent_team svc_agents --> pkg_acp svc_agents --> pkg_agent_loop svc_agents --> pkg_subagent_in_process_driver - svc_apiProxy --> pkg_client_connection svc_approval --> pkg_acp svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_attachments --> pkg_api_session_controller - svc_attachments --> pkg_host_apiproxy svc_attachments --> pkg_llm_deepseek svc_attachments --> pkg_llm_pi_ai svc_attachments --> pkg_tool_fs @@ -360,7 +355,7 @@ flowchart LR svc_codeRuntime --> pkg_tools svc_compaction --> pkg_compaction_basic svc_cordisInspect --> pkg_tool_cordis - svc_credentials --> pkg_host_apiproxy + svc_credentials --> pkg_api_settings_controller svc_credentials --> pkg_llm_deepseek svc_credentials --> pkg_llm_pi_ai svc_deepseekLlmApiExtensions --> pkg_llm_deepseek @@ -393,8 +388,11 @@ flowchart LR svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash - svc_sessionProjectionCache --> pkg_host_apiproxy - svc_sessionProjections --> pkg_host_apiproxy + svc_sessionProjectionCache --> pkg_api_session_controller + svc_sessionProjectionCache --> pkg_session_query + svc_sessionProjectionCache --> pkg_session_reference + svc_sessionProjectionCache --> pkg_subagent + svc_sessionProjections --> pkg_api_session_controller svc_sessionProjections --> pkg_session_title svc_sessionProjections --> pkg_tool_todo svc_sessionQuery --> pkg_session_reference @@ -407,7 +405,7 @@ flowchart LR svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite svc_sessions --> pkg_subagent_in_process_driver - svc_settings --> pkg_host_apiproxy + svc_settings --> pkg_api_settings_controller svc_settings --> pkg_llm_deepseek svc_settings --> pkg_llm_pi_ai svc_shell --> pkg_hooks_claude_code @@ -467,7 +465,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | -| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`host-apiproxy`](../packages/host/apiproxy), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | [`api-session-controller`](../packages/api/session-controller), [`tool-fs`](../packages/fs/tool-fs), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/test-support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compaction-basic`](../packages/compaction/compaction-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.deepseekLlmApiExtensions` | `seam` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | [`session-log-deepseek`](../packages/session/session-log-deepseek), [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | [`llm-deepseek`](../packages/llm/llm-deepseek) | - | 插件准备彼此独立的顶层字段;官方适配器会合并这些字段,并在 HTTP 接受后提交其交付状态。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | @@ -484,9 +482,9 @@ flowchart LR | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/shell/tool-bash), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | -| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | +| `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-file`](../packages/settings/settings-file) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;settings controller 提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.subagentModelSelection` | `core` | [`tool-subagent`](../packages/subagent/tool-subagent) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | - | 拥有默认关闭的设置命名空间;Agent 作用域的委派工具会在组合新顶层 Session 时读取它。 | -| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`host-apiproxy`](../packages/host/apiproxy) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | +| `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`api-settings-controller`](../packages/api/settings-controller), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;settings controller 提供不含实际值的视图和只写存储。 | | `ctx.authorization` | `seam` | [`authorization`](../packages/credentials/authorization) | - | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | flow 由知道如何取得某份凭据的插件注册,并以其写入的记录为键;seam 拥有这段对话与"每个键同时只跑一次尝试"的生命周期,而非协议本身。 | | `ctx.sessionTelemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | @@ -503,11 +501,11 @@ flowchart LR | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | -| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title), [`host-apiproxy`](../packages/host/apiproxy) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,api-proxy 提供基线并推送发生变化的值。 | -| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | +| `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | +| `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`subagent`](../packages/subagent/subagent) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),并提供冷读取阶梯:缓存行加持久化尾部回放,因此列表读取永远不需要加载完整日志。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`headless`](../packages/bundle/headless), [`host-apiproxy`](../packages/host/apiproxy) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | +| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | 通过 settings 分层默认 `ModelSelection`,让直接入口与 Host 支撑的 Agent 入口共享同一个状态所有者。 | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 | | `ctx.e2b` | `core` | [`e2b`](../packages/e2b/e2b) | - | [`fs-e2b`](../packages/e2b/fs-e2b), [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | - | 拥有一个共享的 E2B SDK 句柄、远程工作目录和最终沙箱处置,使两个基础 E2B 提供方处于同一个 Linux 运行时中。 | @@ -534,7 +532,6 @@ flowchart LR | `ctx.workflowEngine` | `seam` | [`workflow`](../packages/workflow/workflow) | [`workflow-worker-thread`](../packages/workflow/workflow-worker-thread) | [`tool-workflow`](../packages/workflow/tool-workflow), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 每个上下文使用一个引擎,与 bash 相同,且没有具名提供方注册表;通用工作流与固定 Ralph 消费方启动运行,其中的 agent() 调用通过 ctx.subagents 扇出。 | | `ctx.webhookRuntime` | `core` | [`webhook`](../packages/webhook/webhook) | - | [`webhook-github`](../packages/webhook/webhook-github) | - | 提供方适配器分派已认证交付;可信插件注册独立的进程本地规则,runtime 把非 null 结果转换为普通的 Workspace-backed Session,不保留交付或完成状态。 | | `ctx.lsp` | `seam` | [`lsp`](../packages/lsp/lsp) | [`lsp-stdio`](../packages/lsp/lsp-stdio) | [`tool-lsp`](../packages/lsp/tool-lsp) | - | 提供方注册与选择,加上恰好四种操作的标准化查询执行;该 seam 不提供协议逃生口,后端必须转换为标准化请求和结果。 | -| `ctx.apiProxy` | `core` | [`host-apiproxy`](../packages/host/apiproxy) | - | [`client-connection`](../packages/client/connection) | - | 与传输无关的 Host 网关接口:它分派浏览器 API 调用,每条打开的 Host 流自行订阅转发事件,而不是由广播方法向其推送。 | | `ctx.dynamicCordisRunner` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 拥有内存定义注册表、Host 半的 vm 沙箱和 request-run 往返流程;浏览器页面通过其 Remote 命名空间在线访问同一服务。 | | `ctx.cordisInspect` | `core` | [`cordis-host-runner`](../packages/extensions/cordis-host-runner) | - | [`tool-cordis`](../packages/extensions/tool-cordis) | - | 注册 Host inspect 提供方、镜像 Client 提供方 manifest,并通过动态 Cordis 传输路由 Client 查询。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 438a3ef9c8..b9d870136b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 05bd17a600869782409038188457db6d362f07a4 -config-catalog.zh.md: 1095b31530a28af2c4a23520fd5e68e067b46c11 +config-catalog.md: 3b05dbf6a9e4a591e876bd6993522223146288fb +config-catalog.zh.md: cbcc3d1acc9664ce3c92eb7dcb3fc0d12a8c466d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 05bd17a600..3b05dbf6a9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -288,7 +288,7 @@ export interface Config { } ``` -Source: [`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) +Source: [`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) @@ -301,6 +301,8 @@ Requires: `agentDefaultModel` · `agents` · `attachments` · `llm` · `sessions export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } ``` @@ -427,7 +429,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:55`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:70`](../packages/client/connection/src/index.ts) @@ -868,34 +870,6 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) - - -## `@deepseek-ai/dsh-host-apiproxy` - -Requires: `agentDefaultModel` · `agents` · `attachments` · `sessions` · `sessionQuery` - -```ts config-catalog -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} -``` - -Source: [`packages/host/apiproxy/src/index.ts:40`](../packages/host/apiproxy/src/index.ts) - ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -1850,6 +1824,25 @@ export interface Config { Source: [`packages/session/session-log-deepseek/src/index.ts:22`](../packages/session/session-log-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-session-log-export` + +Requires: `commands` · `connection` + +```ts config-catalog +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +``` + +Source: [`packages/session-query/session-log-export/src/index.ts:41`](../packages/session-query/session-log-export/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -3471,7 +3464,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-schedule` — requires `agents` · `sessions` · `tools` · `sessionPersistence` ([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) -- `@deepseek-ai/dsh-session-log-export` — requires `commands` ([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — requires `sessionProjections` ([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — requires `skills` ([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 1095b31530..cbcc3d1acc 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -290,7 +290,7 @@ export interface Config { } ``` -来源:[`packages/api/gateway/src/index.ts:114`](../packages/api/gateway/src/index.ts) +来源:[`packages/api/gateway/src/index.ts:117`](../packages/api/gateway/src/index.ts) @@ -303,6 +303,8 @@ export interface Config { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } ``` @@ -870,34 +872,6 @@ export interface Config { 来源:[`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-codex/src/index.ts) - - -## `@deepseek-ai/dsh-host-apiproxy` - -需要:`agentDefaultModel` · `agents` · `attachments` · `sessions` · `sessionQuery` - -```ts config-catalog -/** Gateway plugin configuration. */ -export interface Config { - /** - * Whether this deployment can hand paths to a native desktop opener — - * the `hasDocument` capability the agent-preset roster reports. Absent, - * the platform is asked (macOS/Windows/WSL yes; Linux only with a display - * server); set it explicitly where detection misleads, e.g. `false` in a - * container whose DISPLAY points nowhere a user can see. - */ - nativeOpen?: boolean - /** - * DEFLATE level for every session-log ZIP entry: `0` stores without - * compression, `1` favors CPU/latency, and `9` favors archive size. - * @default 6 - */ - sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 -} -``` - -来源:[`packages/host/apiproxy/src/index.ts:40`](../packages/host/apiproxy/src/index.ts) - ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -1852,6 +1826,25 @@ export interface Config { 来源:[`packages/session/session-log-deepseek/src/index.ts:22`](../packages/session/session-log-deepseek/src/index.ts) + + +## `@deepseek-ai/dsh-session-log-export` + +需要:`commands` · `connection` + +```ts config-catalog +/** Session-log archive policy. */ +export interface Config { + /** DEFLATE level for each ZIP entry. @default 6 */ + readonly compressionLevel?: SessionLogCompressionLevel +} + +/** Valid fflate DEFLATE levels accepted by session-log export. */ +export type SessionLogCompressionLevel = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 +``` + +来源:[`packages/session-query/session-log-export/src/index.ts:41`](../packages/session-query/session-log-export/src/index.ts) + ## `@deepseek-ai/dsh-session-persistence-jsonl` @@ -3473,7 +3466,6 @@ export interface Config { - `@deepseek-ai/dsh-schedule` — 需要 `agents` · `sessions` · `tools` · `sessionPersistence`([`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts)) - `@deepseek-ai/dsh-session`([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — 需要 `llm` · `sessionPersistence` · `sessions` · `tools`([`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts)) -- `@deepseek-ai/dsh-session-log-export` — 需要 `commands`([`packages/session-query/session-log-export/src/index.ts`](../packages/session-query/session-log-export/src/index.ts)) - `@deepseek-ai/dsh-session-projection`([`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts)) - `@deepseek-ai/dsh-session-stats` — 需要 `sessionProjections`([`packages/session/session-stats/src/index.ts`](../packages/session/session-stats/src/index.ts)) - `@deepseek-ai/dsh-skill-badge` — 需要 `skills`([`packages/skill/skill-badge/src/index.ts`](../packages/skill/skill-badge/src/index.ts)) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index fad92b97e3..14704caf43 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 12797904fab31d613db92af0da0656146eeea51a -development.zh.md: c759f4236efa356c5cc82dcec758654362fcc284 +development.md: 2647c0eddacb22d1f96e1e780e4c9f1d16b4ae0d +development.zh.md: b7ac5fb4d46de68f502dbcab8eef50944055718f diff --git a/docs/development.md b/docs/development.md index 12797904fa..2647c0edda 100644 --- a/docs/development.md +++ b/docs/development.md @@ -59,7 +59,7 @@ Host and Client stay two aggregate programs because both sides declaration-merge - A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. - A new package is registered in exactly one aggregate; only the split packages above carry both leaf configs, and the shared leaves are registered in both aggregates because each side must type-check the same source. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase. -Five packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, and `client/connection`. `api/remotes`' Host entry must participate in the Host Typert graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order. +Six packages split Host and Client tsconfigs: `api/remotes`, `api/gateway`, `api/session-controller`, `api/workspace-controller`, `client/connection`, and `session-query/session-log-export`. `api/remotes`' Host entry participates in the Host Typert graph while its Client entry imports generated `/remote` declarations; `session-log-export` keeps Node archive production out of its browser controller. Each split package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf; it discovers split packages from the presence of both leaf configs, so a new split joins the gate automatically. The [`api-remotes` README](../packages/api/remotes/README.md) and [`session-log-export` README](../packages/session-query/session-log-export/README.md) explain their splits. The root build follows the generated dependency order: diff --git a/docs/development.zh.md b/docs/development.zh.md index c759f4236e..b7ac5fb4d4 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -63,7 +63,7 @@ Host 与 Client 保持两个 aggregate program,是因为两侧在相同键下 - 构造全仓 `ts.Program` 的脚本显式以 `tsconfig.host.json` 或 `tsconfig.client.json` 为种子——根 solution 永不作为种子,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。 - 新包只登记进一个 aggregate;只有上述拆分包同时携带两个 leaf 配置,共享 leaf 因两侧需要对同一份源码做类型检查而登记进两个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client 插件的两份运行时产物都在 Client 构建阶段生成。 -拆分 Host/Client tsconfig 的包有五个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller` 与 `client/connection`。`api/remotes` 的 Host 入口必须进入 Host Typert 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此每个拆分包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 说明 Host/Client 拆分与构建顺序。 +拆分 Host/Client tsconfig 的包有六个:`api/remotes`、`api/gateway`、`api/session-controller`、`api/workspace-controller`、`client/connection` 与 `session-query/session-log-export`。`api/remotes` 的 Host 入口进入 Host Typert 图,而 Client 入口导入生成的 `/remote` 声明;`session-log-export` 则让 Node archive 生产代码不进入浏览器 controller。每个拆分包根 `tsconfig.json` 因此只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf;该门禁按「两个 leaf 配置同时存在」自动发现拆分包,所以新拆分的包会自动纳入管辖。[`api-remotes` README](../packages/api/remotes/README.zh.md) 与 [`session-log-export` README](../packages/session-query/session-log-export/README.zh.md)分别说明其拆分。 根构建按生成依赖排序: diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 558d4a4200..445c9ac17a 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7abbcb938f2b2a203523e569422ef4cace658fe8 -module-graph.zh.md: 862f24dfc374e8b783bf94cebe29a58d52d7a02b +module-graph.md: b48c197299f1afbf53bf60962f544148f7f6defc +module-graph.zh.md: 1f85ab06c3ca98d6060dab400d2bf8dbfbda8cc5 diff --git a/docs/module-graph.md b/docs/module-graph.md index 7abbcb938f..b48c197299 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -229,7 +229,6 @@ flowchart TD pkg_tool_call_timeout_policy["tool-call-timeout-policy"] end subgraph group_host["packages/host"] - pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] @@ -384,7 +383,6 @@ flowchart TD pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -443,10 +441,6 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -1023,9 +1017,9 @@ flowchart TD pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_brand pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants @@ -1153,6 +1147,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_host_frontend_static --> pkg_invariants @@ -1583,6 +1581,8 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_attachment + pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale pkg_session_log_export --> pkg_client_ui_commands pkg_session_log_export --> pkg_client_ui_conversation @@ -1590,12 +1590,16 @@ flowchart TD pkg_session_log_export --> pkg_client_ui_session pkg_session_log_export --> pkg_commands pkg_session_log_export --> pkg_invariants + pkg_session_log_export --> pkg_session + pkg_session_log_export --> pkg_session_persistence + pkg_session_log_export --> pkg_session_query pkg_client_ui_attachment --> pkg_attachment pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_api_remotes pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale pkg_client_ui_deliverables --> pkg_client_ui_chat @@ -1733,7 +1737,6 @@ flowchart TD | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1762,7 +1765,6 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1876,7 +1878,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1889,6 +1891,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1941,9 +1944,9 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 862f24dfc3..1f85ab06c3 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -231,7 +231,6 @@ flowchart TD pkg_tool_call_timeout_policy["tool-call-timeout-policy"] end subgraph group_host["packages/host"] - pkg_host_apiproxy["host-apiproxy"] pkg_host_directory_picker["host-directory-picker"] pkg_host_directory_picker_auto["host-directory-picker-auto"] pkg_host_directory_picker_browse["host-directory-picker-browse"] @@ -386,7 +385,6 @@ flowchart TD pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants - pkg_host_apiproxy --> pkg_invariants pkg_host_directory_picker --> pkg_invariants pkg_host_directory_picker_browse --> pkg_invariants pkg_host_directory_picker_native --> pkg_invariants @@ -445,10 +443,6 @@ flowchart TD pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants - pkg_experimental_webworker_runtime --> pkg_client_modules - pkg_experimental_webworker_runtime --> pkg_host_apiproxy - pkg_experimental_webworker_runtime --> pkg_host_webserver - pkg_experimental_webworker_runtime --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -1025,9 +1019,9 @@ flowchart TD pkg_web_app --> pkg_shell_env pkg_web_app --> pkg_system_prompt pkg_client_connection --> pkg_attachment + pkg_client_connection --> pkg_brand pkg_client_connection --> pkg_commands pkg_client_connection --> pkg_credentials - pkg_client_connection --> pkg_host_apiproxy pkg_client_connection --> pkg_host_directory_picker pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants @@ -1155,6 +1149,10 @@ flowchart TD pkg_agent_spine_demo --> pkg_tool_jobs pkg_agent_spine_demo --> pkg_tool_skill pkg_agent_spine_demo --> pkg_tools + pkg_experimental_webworker_runtime --> pkg_client_connection + pkg_experimental_webworker_runtime --> pkg_client_modules + pkg_experimental_webworker_runtime --> pkg_host_webserver + pkg_experimental_webworker_runtime --> pkg_invariants pkg_host_frontend_static --> pkg_client_connection pkg_host_frontend_static --> pkg_host_webserver pkg_host_frontend_static --> pkg_invariants @@ -1585,6 +1583,8 @@ flowchart TD pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver pkg_host_directory_picker_auto --> pkg_invariants + pkg_session_log_export --> pkg_attachment + pkg_session_log_export --> pkg_client_connection pkg_session_log_export --> pkg_client_locale pkg_session_log_export --> pkg_client_ui_commands pkg_session_log_export --> pkg_client_ui_conversation @@ -1592,12 +1592,16 @@ flowchart TD pkg_session_log_export --> pkg_client_ui_session pkg_session_log_export --> pkg_commands pkg_session_log_export --> pkg_invariants + pkg_session_log_export --> pkg_session + pkg_session_log_export --> pkg_session_persistence + pkg_session_log_export --> pkg_session_query pkg_client_ui_attachment --> pkg_attachment pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants + pkg_client_ui_deliverables --> pkg_api_remotes pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale pkg_client_ui_deliverables --> pkg_client_ui_chat @@ -1735,7 +1739,6 @@ flowchart TD | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker`](../packages/host/directory-picker) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1764,7 +1767,6 @@ flowchart TD | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-modules`](../packages/client/modules), [`host-apiproxy`](../packages/host/apiproxy), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | @@ -1878,7 +1880,7 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) | | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`invariants`](../packages/runtime-diagnostics/invariants), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | -| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-apiproxy`](../packages/host/apiproxy), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | +| [`client-connection`](../packages/client/connection) | `client` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`host-directory-picker`](../packages/host/directory-picker), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`tool-todo`](../packages/todo/tool-todo) | | [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | @@ -1891,6 +1893,7 @@ flowchart TD | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`typert-protocol`](../packages/typert/protocol) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs-local`](../packages/jobs/jobs-local), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`shell-env`](../packages/shell/shell-env), [`skill`](../packages/skill/skill), [`skill-filesystem`](../packages/skill/skill-filesystem), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/shell/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-jobs`](../packages/jobs/tool-jobs), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | +| [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) | | [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1943,9 +1946,9 @@ flowchart TD | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) | | [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f917871371..f459ae1fd9 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 919a8eff583886610b56b34294ae1c13a06493b0 -session.zh.md: 8ffc7c8256311328c9e56e63625f3fbfcb5241a7 +session.md: f3c246f7a77386a088f0559d233031bdb8d997f8 +session.zh.md: bc706a11e9b504f6806cbf7c1beb67b972fb4f75 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 919a8eff58..f3c246f7a7 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -649,6 +649,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('modelCatalog') modelCatalog(): Promise +/** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ +@Remote canOpenWorkspacePath(): boolean + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 8ffc7c8256..bc706a11e9 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -653,6 +653,12 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH */ @Remote('modelCatalog') modelCatalog(): Promise +/** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ +@Remote canOpenWorkspacePath(): boolean + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 5b83c19297..2efb877591 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/docs/subsystems/settings.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/settings.md -settings.md: 467d0fc5fb4c97eeb6a18f36d879733e063bd36b -settings.zh.md: 42ed78f321021c5b7b55c51db6ef55b506550057 +settings.md: 3215de191b4ef8fecb373d280c8bc8e4a89bbc7e +settings.zh.md: 0c28ef381ee6ea8e59da64717575fbf80c8a2e5f diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index 467d0fc5fb..3215de191b 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -273,6 +273,12 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue +/** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ +@Remote canOpenAgentPresetDirectory(): boolean + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 42ed78f321..0c28ef381e 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -273,6 +273,12 @@ Host service backing the generated `ctx.remote.settings` namespace. Every remote */ @Remote describe(): SettingsDescribeValue +/** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ +@Remote canOpenAgentPresetDirectory(): boolean + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/docs/subsystems/typert.i18n.yaml b/docs/subsystems/typert.i18n.yaml index 598956872e..498532ed69 100644 --- a/docs/subsystems/typert.i18n.yaml +++ b/docs/subsystems/typert.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/typert.md -typert.md: bf43280f7fa01e6300caeaadeadb2c6c8f78cdd2 -typert.zh.md: c50663ad2546d864efc5648059dde735ae4de5bd +typert.md: 20734175cc6b853ae7eeb18e6d5fc91a97cdbe0d +typert.zh.md: e900011f24a5a232ab8764501594b563f320e090 diff --git a/docs/subsystems/typert.md b/docs/subsystems/typert.md index bf43280f7f..20734175cc 100644 --- a/docs/subsystems/typert.md +++ b/docs/subsystems/typert.md @@ -185,9 +185,13 @@ interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -238,14 +242,6 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.apiProxy` — `ApiProxy` - -Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) - ### `ctx.typert` — `TypertRegistry` @@ -322,9 +318,10 @@ Resolve strict generated definitions or conservative SRC markers against current /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ -registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise /** * Invoke one live Remote method through strict generated reflection or SRC markers. diff --git a/docs/subsystems/typert.zh.md b/docs/subsystems/typert.zh.md index c50663ad25..e900011f24 100644 --- a/docs/subsystems/typert.zh.md +++ b/docs/subsystems/typert.zh.md @@ -185,9 +185,13 @@ interface TypertGateway { /** * Register the application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this exact source and cancelling its active streams. */ - registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + registerRemoteEvents( + source: TypertRemoteEventSource, + host: RemoteEventHostInfo, + ): () => Promise /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. @@ -238,14 +242,6 @@ interface TypertClientRemote extends TypertRemoteNamespaceMap { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.apiProxy` — `ApiProxy` - -Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. - -Source: [`packages/host/apiproxy/src/api/index.ts`](../../packages/host/apiproxy/src/api/index.ts) - ### `ctx.typert` — `TypertRegistry` @@ -322,9 +318,10 @@ Resolve strict generated definitions or conservative SRC markers against current /** * Register the sole application-selected forwarded-event source. * @param source - stream factory installed by the Remote assembly. + * @param host - stable Host facts included in each Client generation's opening frame. * @returns disposer removing this source and cancelling its active streams. */ -registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +registerRemoteEvents( source: TypertRemoteEventSource, host: RemoteEventHostInfo, ): () => Promise /** * Invoke one live Remote method through strict generated reflection or SRC markers. diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 98e549a149..5a83be6c89 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-client.md -web-client.md: e67da4980af881e426f2bc031c6ec49cc1df2857 -web-client.zh.md: c5e9904226de66381a54bcb8a4783a062d70750f +web-client.md: 166ad50df661e37318c5ed2f271569c292cce39a +web-client.zh.md: cdf91958e23c0ea5c99562e6ca347947fbeff292 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index e67da4980a..166ad50df6 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -27,9 +27,9 @@ The Web boot kernel creates the module system, prefetches `immediately` entries, Host business services annotate callable methods with Typert Remote decorators. Host generation emits strict descriptors, runtime codecs, declaration merges, and source maps. The Client-side `api-remotes` assembly selects those generated contributions and mounts concrete methods under `ctx.remote.` and Session-scoped `agentCtx.remote.`. Feature packages depend on the generated service face, not the Gateway implementation or a Host package's runtime entry. -The Connection owns request correlation, the `/api` carrier, trust checks, Host description, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. API Proxy handles only `/api` endpoints that no strict Remote descriptor claims; new controller operations belong on generated Remote methods or explicit Remote streams. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. +The Connection owns request correlation, the `/api` carrier, trust checks, exact Fetch routes, and connection generations. API Gateway owns Remote dispatch, cancellation, logical streams, and selected Host event forwarding. Controller operations belong on generated Remote methods or explicit Remote streams; feature-owned downloads register exact Fetch routes. The [API Gateway reference](../api-gateway.md) defines generation and invocation, while the [Connection README](../../packages/client/connection/README.md) defines the physical carrier and trust policy. -The internal `$events` logical stream is the Connection generation source. A generation becomes connected only after the event source emits `ready` and `host.describe` succeeds. Host listeners are therefore attached before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. +The internal `$events` logical stream is the Connection generation source. Its opening `ready` frame carries the Host home used for path display and establishes the generation after Host listeners are attached, before any controller begins a baseline read. `ctx.remote.$on()` delivers allowlisted ordinary events to the root Client Context and scoped waterfall events to the resolved Session Context; a waterfall listener returns a result, calls `next()`, or rejects. ## Client models diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index c5e9904226..cdf91958e2 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -27,9 +27,9 @@ Web boot kernel 创建模块系统、预取 `immediately` entry、挂载 vendore Host 业务 service 使用 Typert Remote decorator 标记可调用 method。Host generation 产出严格 descriptor、runtime codec、declaration merge 与 source map。Client 侧 `api-remotes` assembly 选择这些生成贡献,并把具体 method 挂到 `ctx.remote.` 与 Session scope 的 `agentCtx.remote.`。功能包依赖生成的 service face,而不依赖 Gateway 实现或 Host 包的运行时 entry。 -Connection 拥有 request correlation、`/api` carrier、trust check、Host description 与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。API Proxy 只处理没有被严格 Remote descriptor 认领的 `/api` endpoint;新的 controller 操作应进入生成的 Remote method 或显式 Remote stream。[API Gateway 参考](../api-gateway.zh.md)定义生成与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 +Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fetch 路由与 connection generation。API Gateway 拥有 Remote dispatch、取消、logical stream 与选定 Host event 的转发。Controller 操作应进入生成的 Remote method 或显式 Remote stream;功能自有的下载则注册精确 Fetch 路由。[API Gateway 参考](../api-gateway.zh.md)定义 generation 与调用,[Connection README](../../packages/client/connection/README.zh.md)定义物理 carrier 与信任策略。 -内部 `$events` logical stream 是 Connection generation source。只有 event source 发出 `ready` 且 `host.describe` 成功后,一代 connection 才会进入 connected。Host listener 因而先于任何 controller baseline read 挂载。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 +内部 `$events` logical stream 是 Connection generation source。它的 opening `ready` frame 携带用于路径显示的 Host home,并在 Host listener 已挂载、任何 controller 开始 baseline read 之前建立 generation。`ctx.remote.$on()` 把 allowlist 内的普通 event 交付给 root Client Context,并把 scoped waterfall event 交付给已解析的 Session Context;waterfall listener 可以返回结果、调用 `next()` 或拒绝。 ## Client models diff --git a/docs/subsystems/web-server.i18n.yaml b/docs/subsystems/web-server.i18n.yaml index 87cca8b0b2..bec49cab10 100644 --- a/docs/subsystems/web-server.i18n.yaml +++ b/docs/subsystems/web-server.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/web-server.md -web-server.md: b806f5b40a2f5ddada40752367e3da23e86ea13d -web-server.zh.md: e5f4a8794d46a2b55d4a69e2cdd13c7dc4c2a6dd +web-server.md: d9b1ec007bc73274bb0c8b5d58745da1c76e6cb6 +web-server.zh.md: 66375534a89e2c4674e748d4178cb7a31761e57e diff --git a/docs/subsystems/web-server.md b/docs/subsystems/web-server.md index b806f5b40a..d9b1ec007b 100644 --- a/docs/subsystems/web-server.md +++ b/docs/subsystems/web-server.md @@ -2,7 +2,7 @@ English | [中文](web-server.zh.md) -[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. +[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: a single `node:http` plugin providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; it knows no harness concepts, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering note](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server. Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/docs/subsystems/web-server.zh.md b/docs/subsystems/web-server.zh.md index e5f4a8794d..66375534a8 100644 --- a/docs/subsystems/web-server.zh.md +++ b/docs/subsystems/web-server.zh.md @@ -2,7 +2,7 @@ [English](web-server.md) | 中文 -[dsh-host-webserver](../../packages/host/webserver) 是 GUI 宿主的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 +[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:它是一个提供 `ctx.webServer` 的 `node:http` 插件,包含具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它不了解任何 harness 概念。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。 源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts) diff --git a/packages/AGENTS.md b/packages/AGENTS.md index e753147a72..af47b16740 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -19,7 +19,7 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md [Naming rules](../docs/cookbook/adding-a-package.md#name-the-role-that-exists): -- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `runtime-diagnostics/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). +- **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), sets `rootDir: src` and `outDir: lib/types`, references workspace dependencies plus `runtime-diagnostics/invariants`, and registers in one aggregate. Packages with distinct Host and Client compiler faces use `tsconfig.host.json` and `tsconfig.client.json` leaves plus a solution-only root; ordinary two-entry Client plugins do not split ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. - Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts). diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index b93fd190e7..c47c67f406 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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/api/README.md -README.md: aaea6ef47aabdc8baa63ef7a875de065bcae94b9 -README.zh.md: 8e87f97c413620ede4ee160384d00f18382cfdf7 +README.md: b4d8ddd84baa411a2675c95dda1701937e06fe6d +README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec diff --git a/packages/api/README.md b/packages/api/README.md index aaea6ef47a..b4d8ddd84b 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -32,19 +32,18 @@ The packages below provide the Remote layer; the package READMEs own the exhaust | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | -Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Endpoints without a Remote definition fall through to the application's API Proxy. +Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the controller packages own Session, configuration-surface, and Workspace behavior. Feature packages register exact Connection Fetch routes for responses that do not fit Remote invocation, such as streamed downloads. ----- ## Related documentation -Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions, and the carrier and fallback packages for how calls travel and how endpoints without Remote definitions are served. +Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions and Connection for the physical carrier. - [API Gateway reference](../../docs/api-gateway.md) — the current-state reference for the Typert API Gateway: programming model, generation pipeline, and runtime invocation. - [Typert subsystem reference](../../docs/subsystems/typert.md) — the public contracts shared by protocol, Gateway, and consumer assemblies. - [Connection](../client/connection/README.md) — the RPC carrier, `/api` trust fence, and response envelopes behind every Remote call. -- [API Proxy](../host/apiproxy/README.md) — the fallback for endpoints without Remote descriptors. ## Dev Note diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 8e87f97c41..5dff7a5921 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -32,19 +32,18 @@ kind: "package-group" | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | -Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。没有 Remote 定义的 endpoint 会回退到应用的 API Proxy。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,各 controller 包分别拥有 Session、配置界面与 Workspace 行为。流式下载等不适合 Remote 调用的响应由功能包注册精确的 Connection Fetch 路由。 ----- ## 相关文档 -先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,以及载体与回退包了解调用如何传输、没有 Remote 定义的 endpoint 如何被服务。 +先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,并通过 Connection 了解物理载体。 - [API Gateway 参考](../../docs/api-gateway.zh.md)——Typert API Gateway 的现状参考:编程模型、生成流水线与运行时调用。 - [Typert 子系统参考](../../docs/subsystems/typert.zh.md)——protocol、Gateway 与消费方装配共享的公共约定。 - [Connection](../client/connection/README.zh.md)——每次 Remote 调用背后的 RPC 载体、`/api` 信任围栏与响应封装。 -- [API Proxy](../host/apiproxy/README.zh.md)——没有 Remote 描述符的 endpoint 的回退路径。 ## 开发备注 diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 8353039833..f1d957d143 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 504ff95494d8c374426c18d0a77fb238457561e3 -README.zh.md: e556b4981ce5789e6fe9f74bb7d4dc9c5217ae4c +README.md: d10eed46d5534a2459995bd687942d799f009c7b +README.zh.md: 310b44cfe633758089771cda4040679c09373ec2 diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 504ff95494..d10eed46d5 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -28,13 +28,13 @@ Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. -The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. +The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and returns 404 for unclaimed requests unless an exact Fetch route owns them. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. The Host sends Ping control frames at the configured `websocketHeartbeatIntervalMs` interval (30 seconds by default), and the browser answers Pong at the WebSocket protocol layer, so idle network intermediaries see traffic without any Remote stream frame. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. -Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, per-Client queues, and the Host home sent in the opening `{ type: 'ready', clientId, host: { home } }` frame. Its source factory attaches incremental listeners synchronously, so the Client publishes the generation and starts baseline reads only after incremental delivery is ready. ## Client service: `ClientRemote` (ctx key: `remote`) @@ -45,7 +45,7 @@ Each unary call validates positional inputs, constructs the descriptor's exact n `ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the `ready` item and `host.describe` jointly establish a Connection generation. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the opening `ready` item establishes a Connection generation and supplies its Host facts. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index e556b4981c..310b44cfe6 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -28,13 +28,13 @@ kind: "package-reference" 严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 -Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 +Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领且没有精确 Fetch 路由负责的请求返回 404。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。Host 按配置的 `websocketHeartbeatIntervalMs` 间隔(默认 30 秒)发送 Ping 控制帧,浏览器在 WebSocket 协议层自动回复 Pong,使空闲网络中间层持续看到流量,而不新增 Remote stream frame。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 -Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验、每 Client 队列及 opening `{ type: 'ready', clientId, host: { home } }` frame 中的 Host home 由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener,因此 Client 只在增量投递就绪后发布 generation 并开始 baseline 读取。 ## Client 服务:`ClientRemote`(ctx key:`remote`) @@ -45,7 +45,7 @@ Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source `ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;`ready` 项与 `host.describe` 共同建立一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;opening `ready` 项建立 Connection generation 并提供 Host 信息。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index c1158e8aa7..43991614b9 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: 50361d1a6ae2f0b3b53a5bd928429414ab12bbce -README.zh.md: 5545198cce2e43513af73adfc9d4277831adae92 +README.md: 7f855bd1cd37c0799f10cadfaf2167fe34f7ea40 +README.zh.md: edad8d735dd914e44bcf283336f78b243e3715d9 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 50361d1a6a..7f855bd1cd 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -40,18 +40,18 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. -The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active. Withdrawing the registration aborts active streams; API Proxy does not participate in event forwarding or Connection generation. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. ## Build boundary -An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. +Most repository packages belong to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. This package splits because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. This package's root `tsconfig.json` is only a solution that references `tsconfig.host.json` and `tsconfig.client.json`. The Host aggregate and direct Host consumers reference the former, while the Client aggregate and direct Client consumers reference the latter; the package-root solution must not enter either aggregate's dependency graph. The two projects own disjoint source files and `.tsbuildinfo` files but share the `lib/types` output directory, with one deliberate exception: `src/remote-events.ts` and `src/types.ts` are listed in BOTH faces' `files`, because the forwarded-event allowlist is the single control point over what a consumer can receive, and the Host forwarding loop and the Client `ctx.remote.$on` key face must read one declaration rather than two that could drift. That exception is not just a `files` entry. The root `tsconfig.base.json` maps `@deepseek-ai/dsh-api-remotes/types` to `src/types.ts` — the source plane, like every other workspace subpath and unlike the generated `/remote` artifacts, which have no `paths` entry and resolve through `exports` to built output. Both faces therefore admit the same allowlist and type projection into their own programs and emit byte-identical `remote-events` and `types` outputs into `lib/types`; the `.tsbuildinfo` files stay independent. No gate enforces the faces' source-file disjointness — `scripts/project-reference-faces.ts` only checks that a reference into a split project names the matching face — so this paragraph records why the double listing is intentional. -The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. +The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; split only when the two source sets require different compiler faces. ## Model Experience diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 5545198cce..edad8d735d 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -40,18 +40,18 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 -Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项能证明增量投递已就绪。撤回注册会中止活动 stream;API Proxy 不参与事件转发或 Connection generation。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 ## 构建边界 -仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 +仓库中的多数包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。本包需要拆分,因为 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 本包根 `tsconfig.json` 只是引用 `tsconfig.host.json` 与 `tsconfig.client.json` 的 solution。Host aggregate 和 Host 直接消费方引用前者,Client aggregate 和 Client 直接消费方引用后者;禁止把包根 solution 放进任一 aggregate 的依赖图。两个 project 拥有互不重叠的源码和 `.tsbuildinfo`,但共享 `lib/types` 输出目录——只有一处刻意的例外:`src/remote-events.ts` 与 `src/types.ts` **同时**列进两个 face 的 `files`,因为转发事件名单是「消费端能收到什么」的唯一控制点,Host 转发循环与 Client 的 `ctx.remote.$on` 键面必须读同一份声明,而不是两份可能彼此漂移的声明。 这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。 -包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 +包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;只有两组源码需要不同 compiler face 时才拆分。 ## 模型体验 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 41a4c618d3..fc66cdfceb 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: c25a7b53908f140ce866c984938401116f8e0c54 -README.zh.md: 4cc8d550e1685d8f15c71279135ede5826011f5f +README.md: 689635b83bce71e3aecfedf371685a24bede55ab +README.zh.md: 68da7db60b38aca1b72c991005438ced45b348ea diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index c25a7b5390..689635b83b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,7 +25,7 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. @@ -39,6 +39,7 @@ The Session object also carries local submission echoes: `session.beginSubmissio | Field | Default | Meaning | |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | +| `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 4cc8d550e1..68da7db60b 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 @@ -39,6 +39,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 | 字段 | 默认值 | 含义 | |---|---:|---| | `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | +| `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 diff --git a/packages/api/settings-controller/README.i18n.yaml b/packages/api/settings-controller/README.i18n.yaml index 3b05b15761..cc5d27a374 100644 --- a/packages/api/settings-controller/README.i18n.yaml +++ b/packages/api/settings-controller/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/api/settings-controller/README.md -README.md: b545b27ff1716e54be1a25a9bbd4131f86a12b68 -README.zh.md: 46977c6e0e3fbd43eb0a688b37e7db6eb7007f85 +README.md: 5032b8ff352d35abc05384719932691a93a49f84 +README.zh.md: 756a7fcdd1cd03b157ff5781aadd4db092c6d374 diff --git a/packages/api/settings-controller/README.md b/packages/api/settings-controller/README.md index b545b27ff1..5032b8ff35 100644 --- a/packages/api/settings-controller/README.md +++ b/packages/api/settings-controller/README.md @@ -29,7 +29,7 @@ Mount this package as a Loader entry in a profile that serves browser configurat `settings.describe()` returns deployment facts and every namespace under `redactSecrets: true`. `settings.update`, `settings.replace`, and `settings.mutate` expose the settings service's three write operations and return the namespace's new redacted view; stale writes use `settings-conflict` and other provider refusals use `settings-rejected`. -`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither method accepts a browser-supplied filesystem target. +`settings.openSettingsDocument()` prepares the provider-owned document and opens it with the native text-editor intent. `settings.canOpenAgentPresetDirectory()` reports native-opening availability when the preset page becomes visible. `settings.openAgentPresetDirectory(id)` resolves only a user-authored preset and either opens its directory or returns the path when native opening is unavailable; neither open method accepts a browser-supplied filesystem target. ----- diff --git a/packages/api/settings-controller/README.zh.md b/packages/api/settings-controller/README.zh.md index 46977c6e0e..756a7fcdd1 100644 --- a/packages/api/settings-controller/README.zh.md +++ b/packages/api/settings-controller/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" `settings.describe()` 返回部署信息,以及在 `redactSecrets: true` 下读取的所有 namespace。`settings.update`、`settings.replace` 与 `settings.mutate` 暴露 settings service 的三种写入操作,并返回该 namespace 的新脱敏视图;过期写入使用 `settings-conflict`,其他 provider 拒绝使用 `settings-rejected`。 -`settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两种方法都不接受浏览器提供的文件系统目标。 +`settings.openSettingsDocument()` 准备 provider 持有的文档,并用原生文本编辑器意图将其打开。`settings.canOpenAgentPresetDirectory()` 在 preset 页面显示时报告原生打开能力。`settings.openAgentPresetDirectory(id)` 只解析用户创作的 preset,并在原生打开不可用时返回目录路径;两个打开方法都不接受浏览器提供的文件系统目标。 ----- diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 27f1761cea..29a111effe 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -50,7 +50,7 @@ The stack has one-way knowledge, documented in the [Web Client architecture](../ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. -- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

    `, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). +- **rpcId is strictly bidirectional**: the initiator mints, the responder echoes, and minting stays in Connection ([unary Remote migration](../../.agents/notes/implemented/architecture/2026-08-10-unary-apiproxy-remote-migration.md)). - **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. - **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule). diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 75ab67d3de..6be2c4aa21 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: 72f8ee150c1879f920f189ad3d0221fce449c451 -README.zh.md: 3ef1e22554f568e5d4fe24556fafbc7ce0413209 +README.md: af757608aa7face6854aaf9d103654f974b51ed4 +README.zh.md: fef7248abe1e19595b90c43a6a1b9abf0aafa88a diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 72f8ee150c..af757608aa 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,5 +1,5 @@ --- -description: "Browser-host wire layer for the web GUI: the shared API client, event-stream delivery with reconnect, the /api HTTP bridge, and the browser-trust fence, for users and maintainers composing or debugging the connection." +description: "Browser-host wire layer for the web GUI: Remote RPC, event-stream delivery with reconnect, exact Fetch routes, the /api HTTP bridge, and the browser-trust fence." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff. +The package carries browser-to-Host Remote calls, exact Fetch responses, and connection generations. The Client plugin mounts `ctx.connection` with current-page loopback state, a generic RPC carrier, the active generation and its Host facts, and the registration point for one generation source. A generation becomes visible when its source reports ready; source completion, failure, withdrawal, or an explicit stop clears it before `ConnectionController` reconnects with backoff. ## Table of Contents @@ -25,7 +25,7 @@ Protocol and connection-generation layer. The Client plugin mounts `ctx.connecti ## Use this package -The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal to the browser-facing Client state. +The browser uses HTTP POST for Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, Host/Origin checks, and exact `GET`/`HEAD` route registry. Typert Gateway claims generated Remote endpoints, feature packages register non-JSON responses such as Session-log downloads, and unclaimed requests return 404. Loopback hostname classification remains package-internal to the browser-facing Client state. ----- @@ -41,9 +41,9 @@ Before authentication, every request still passes `src/api-request-trust.ts`. It ## Connection generation -API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready' }` item before events. `ConnectionController` waits for that item and `host.describe` in parallel; `onConnected` cannot start baseline reads until both succeed, so baseline acquisition cannot race ahead of incremental observation. +API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready', clientId, host: { home } }` item before events. `ConnectionController` publishes that generation and calls `onConnected` only after the ready item arrives, so baseline acquisition cannot race ahead of incremental observation. -An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws `hostDescription`, publishes `reconnecting`, and rebuilds the `$events` plus `host.describe` handshake after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws the generation, publishes `reconnecting`, and reopens `$events` after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 3ef1e22554..fef7248abe 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向用户与维护者的浏览器-宿主线层说明:共享 API 客户端、带重连的事件流投递、/api HTTP 桥与浏览器信任栅栏,用于组合或排查连接。" +description: "Web GUI 的浏览器-Host 线层:Remote RPC、带重连的事件流投递、精确 Fetch 路由、/api HTTP 桥与浏览器信任栅栏。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 +本包承载浏览器到 Host 的 Remote 调用、精确 Fetch 响应与 connection generation。Client 插件挂载 `ctx.connection`,其中包含当前页面的 loopback 状态、通用 RPC carrier、当前 generation 及其 Host 信息,以及单一 generation source 的注册点。source 报告 ready 后 generation 才可见;source 结束、失败、被撤回或显式 stop 都会清空它,再由 `ConnectionController` 退避重连。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 +浏览器通过 HTTP POST 执行 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证、Host/Origin 校验与精确 `GET`/`HEAD` 路由注册表。Typert Gateway 认领生成的 Remote endpoint,功能包注册 Session 日志下载等非 JSON 响应,未认领的请求返回 404。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 ----- @@ -41,9 +41,9 @@ cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-sessi ## Connection generation -API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready' }` 项,再发送事件。`ConnectionController` 并行等待该 ready 与 `host.describe`;只有两者都成功才允许 `onConnected` 启动 baseline 读取,因此 baseline 不会跑在增量 listener 前面。 +API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready', clientId, host: { home } }` 项,再发送事件。`ConnectionController` 仅在收到该 ready 项后发布 generation 并调用 `onConnected`,因此 baseline 不会跑在增量 listener 前面。 -`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 `hostDescription`、发布 `reconnecting`,并在退避后重建 `$events` 与 `host.describe` 握手。Gateway mux 自己负责重建底层 WebSocket;Connection 世代负责重建 logical stream 与 baseline 起点。 +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 generation、发布 `reconnecting`,并在退避后重开 `$events`。Gateway mux 自己负责重建底层 WebSocket;Connection generation 负责重开 logical stream 并建立下一次 baseline 起点。 ## 模型体验 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index f3461bcbf7..042caf6f1b 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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-agent-preset/README.md -README.md: 495d38631257bef3f9dbf74b7762c357b90779e4 -README.zh.md: fdf0a994b00c8a28e2bd0dc4dfa7238d25c4774b +README.md: 60a7f0ec9356c6107a32bb7c828746d54511d0f6 +README.zh.md: 64663fd76e0a8e3fd4cb799b05e81db7b62c0bde diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 495d386312..60a7f0ec93 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -43,7 +43,7 @@ When the roster carries the self-referential `cordis` preset, a dashed add-card

    Implementation internals — click to expand -Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change. +Options and the current default both come from one `agentPresets/list` call — the roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection — and the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip and the header label share one controller, because the staged choice belongs to the flow rather than to any one session; the stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
    diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index fdf0a994b0..64663fd76e 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference"
    实现细节——点击展开 -选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的字段。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。 +选项与当前默认值都来自同一次 `agentPresets/list` 调用——名单本身已报告未显式选择的会话会得到哪个 id,因此该行无需对 settings schema 做内省——写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是 Host 在创建时解析的字段。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 与标题标签共用一个控制器,因为暂存选择属于流程而非任何单个会话;暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。被拒绝会以一条瞬时横幅在 composer 列上方自报,因为 chip 的标签此时已经弹回,而被 Host 拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有人刚做出的选择会被自报;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有 Host API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而 copy、delete 与 settings 所有的目录打开操作负责管理名单并驱动 Host 桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,线上没有任何机制宣布文件变动。
    diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 83db7573d4..60f05a20e3 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/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-deliverables/README.md -README.md: e5f9bf2a4ccbf6218a714762bc34a1a443c63022 -README.zh.md: 420a1e28a2893ca41cf24b720b2fbe416b1a38f6 +README.md: 04f9ff96c1573eb66a64de1d174e257c3a3608a2 +README.zh.md: f960d3da993fee3166486d8407c32e4f18278153 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index e5f9bf2a4c..04f9ff96c1 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -25,7 +25,7 @@ This package renders the deliverables row a finished turn ends with — the file ## Use this package -Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; a **Show in folder** action opens the session workspace when the page is loopback and the Host reports it can open paths. +Mount this plugin alongside `ui-conversation`; a finished turn then ends with the produced-files row between the closing message's body and its action footer. Each chip opens the file through the Host opener, with relative paths resolved against the session cwd; when the row first appears, it queries `session.canOpenWorkspacePath()`, and a **Show in folder** action opens the session workspace only when the page is loopback and that query succeeds with `true`. ### The row @@ -87,7 +87,7 @@ These limits define the current deliverables vocabulary. They are current packag - **Mention matching is exact path or unique basename only** — a suffix mention stays inert; widening the matcher is deferred until a real closing-message shape needs it. - **Files created indirectly by terminal commands remain outside the matching vocabulary** — naming such a file in inline code does not make it clickable unless a successful mutation location also records that path. -- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the gateway's `nativeOpen: false`. +- **Native folder handoff targets the Host desktop** — a browser reached through a non-loopback authority omits the action, as does a deployment reporting no native opener; SSH forwarding that makes a remote Host look loopback-local must set the Session Controller's `nativeOpen: false`. ### Dev Note diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 420a1e28a2..f960d3da99 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经宿主打开器打开文件,相对路径按会话 cwd 解析;页面为 loopback 且宿主报告可打开路径时,**在文件夹中显示**动作会打开会话工作区。 +与 `ui-conversation` 一起挂载本插件;已完成轮次随即以产出文件行收尾,位于收尾消息正文与其动作页脚之间。每个标签项经 Host 打开器打开文件,相对路径按会话 cwd 解析;该行首次显示时会查询 `session.canOpenWorkspacePath()`,只有页面为 loopback 且查询成功返回 `true` 时,**在文件夹中显示**动作才会打开会话工作区。 ### 该行 @@ -87,7 +87,7 @@ Node 半部注册静态 `ui:deliverable-file-references` 系统提示词段, - **提及匹配只认精确路径或唯一 basename**——后缀式提及保持惰性;等真实的收尾消息形态产生需求后再放宽匹配规则。 - **终端命令间接创建的文件仍不在匹配词表内**——除非某个成功修改位置也记录了该路径,否则在行内代码中点名这类文件不会使其可点击。 -- **原生文件夹交接以宿主桌面为目标**——经非 loopback 权威访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端宿主看似 loopback 本地,部署必须为网关设置 `nativeOpen: false`。 +- **原生文件夹交接以 Host 桌面为目标**——经非 loopback authority 访问的浏览器会省略该动作,报告没有原生打开器的部署也一样;若 SSH 转发让远端 Host 看似 loopback 本地,部署必须为 Session Controller 设置 `nativeOpen: false`。 ### 开发备注 diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index 4f562d8962..9503648c05 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/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/README.md -README.md: e066f25f6413f72a66e9f0df487de3f32e1e42d4 -README.zh.md: 44fc3077234a8a26f26f349ba1494931127af919 +README.md: 19b4debcaa3de4ca2370f8900adc34205741db82 +README.zh.md: 2870048e3fcb9ddb4303a917d54160dd8b3f1e6e diff --git a/packages/host/README.md b/packages/host/README.md index e066f25f64..19b4debcaa 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for the web GUI host half: the shared API gateway, the HTTP server it rides on, the SPA dist server, the workspace-directory picking seam, and the plugin inventory projection." +description: "Package map for the web GUI host half: the HTTP and SPA servers, workspace-directory picking implementations, and the plugin inventory projection." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `host/` group is the web GUI host half: the API gateway every client shape shares, the plain HTTP server it rides on, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, and the read-only plugin inventory projection. All eight packages are product packages; the browser half that consumes the gateway lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The gateway contract is transport-independent, and the picker backends replace one another behind the shared seam. +The `host/` group provides the web GUI's plain HTTP server, the SPA dist server that serves the built Web shell, the workspace-directory picking seam with its native, browse, and adaptive composition packages, and the read-only plugin inventory projection. All seven packages are product packages; the browser transport lives in [`client/`](../client/README.md), and the composed application is [`apps/cli`](../../apps/cli/README.md) booting the [`dsh-base` bundle](../bundle/base/cordis.patch.yml) that serves the web app under `apps/web/`. The picker backends replace one another behind the shared seam. ## Table of Contents @@ -22,11 +22,10 @@ The `host/` group is the web GUI host half: the API gateway every client shape s ## Packages -Eight packages play the host roles; each package README owns its contract and configuration. +Seven packages play the host roles; each package README owns its contract and configuration. | Package | Role | ctx key | |---|---|---| -| [`apiproxy/`](apiproxy/README.md) | Shared API gateway: the typed client↔host contract, fetch carriers, and the gateway service | `ctx.apiProxy` | | [`webserver/`](webserver/README.md) | Browser HTTP server: named routes, upgrades, index taps, and the fallback seat | `ctx.webServer` | | [`frontend-static/`](frontend-static/README.md) | SPA dist server on the webserver fallback seat | consumes `ctx.webServer` | | [`directory-picker/`](directory-picker/README.md) | Workspace-directory picking seam: capability contract and error vocabulary | `ctx.directoryPicker` | @@ -40,11 +39,11 @@ Eight packages play the host roles; each package README owns its contract and co ## Related documentation -Start with the subsystem references for the transport and the workspace records, then the layering decision behind the gateway. +Start with the subsystem references for the transport and the workspace records, then the layering decision behind the Web client. - [HTTP server subsystem](../../docs/subsystems/web-server.md) — the webserver's routes, matching order, and config. - [Workspace subsystem](../../docs/subsystems/workspace.md) — the workspace records the directory picker feeds. -- [GUI layering and RPC protocol RFC](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — why the gateway contract is channel-independent. +- [Web config-tree boot and transport layering](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — ownership of the Web transport layers. ## Dev Note diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 44fc307723..2870048e3f 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -1,5 +1,5 @@ --- -description: "web GUI 宿主侧的包映射:共享 API 网关、承载它的 HTTP 服务器、SPA dist 服务器、工作区目录选择 seam 与插件清单投影。" +description: "Web GUI Host 侧的包映射:HTTP 与 SPA 服务器、工作区目录选择实现和插件清单投影。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`host/` 组是 web GUI 宿主侧:所有客户端形态共享的 API 网关、承载它的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam,以及只读的插件清单投影。这八个包都是产品包;消费网关的浏览器半侧位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 web 应用。网关约定与传输无关,选择器后端可在共享 seam 后互相替换。 +`host/` 组提供 Web GUI 的普通 HTTP 服务器、服务已构建 Web 壳的 SPA dist 服务器、带原生/浏览/自适应组合包的工作区目录选择 seam,以及只读的插件清单投影。这七个包都是产品包;浏览器传输位于 [`client/`](../client/README.zh.md),组合应用是 [`apps/cli`](../../apps/cli/README.zh.md),它启动 [`dsh-base` 组合包](../bundle/base/cordis.patch.yml) 来提供 `apps/web/` 下的 Web 应用。选择器后端可在共享 seam 后互相替换。 ## 目录 @@ -22,11 +22,10 @@ kind: "package-group" ## 包 -八个包分别承担宿主角色;各包的 README 拥有自己的约定与配置。 +七个包分别承担 Host 角色;各包的 README 拥有自己的约定与配置。 | 包 | 职责 | ctx 键 | |---|---|---| -| [`apiproxy/`](apiproxy/README.zh.md) | 共享 API 网关:类型化的客户端↔宿主约定、fetch 载体与网关服务 | `ctx.apiProxy` | | [`webserver/`](webserver/README.zh.md) | 浏览器 HTTP 服务器:具名路由、upgrade、index 转换与回退席位 | `ctx.webServer` | | [`frontend-static/`](frontend-static/README.zh.md) | 占据 webserver 回退席位的 SPA dist 服务器 | 消费 `ctx.webServer` | | [`directory-picker/`](directory-picker/README.zh.md) | 工作区目录选择 seam:能力约定与错误词汇 | `ctx.directoryPicker` | @@ -40,11 +39,11 @@ kind: "package-group" ## 相关文档 -先从传输与工作区记录的子系统参考读起,再看网关背后的分层决策。 +先从传输与工作区记录的子系统参考读起,再看 Web Client 背后的分层决策。 - [HTTP 服务器子系统](../../docs/subsystems/web-server.zh.md)——webserver 的路由、匹配顺序与配置。 - [工作区子系统](../../docs/subsystems/workspace.zh.md)——目录选择器所喂给的工作区记录。 -- [GUI 分层与 RPC 协议 RFC](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——网关约定为何与通道无关。 +- [Web 配置树启动与传输分层](../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——Web 传输各层的所有权。 ## 开发备注 diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml index 666f003a4a..e5f84d4a7a 100644 --- a/packages/host/webserver/README.i18n.yaml +++ b/packages/host/webserver/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/webserver/README.md -README.md: 73b78511ca3c7d20dfab78499e052b00bc662a28 -README.zh.md: 00524e4ecc5f307fdadc60bb749d8628a4871856 +README.md: 4211c640a0e7386e3380a4289e5ba619818bd094 +README.zh.md: 6ad4f549cd234221cfef88ef0bfd9541fbe50043 diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md index 73b78511ca..4211c640a0 100644 --- a/packages/host/webserver/README.md +++ b/packages/host/webserver/README.md @@ -88,7 +88,7 @@ Read these when the server contract is not enough: the subsystem reference, then - [HTTP server subsystem](../../../docs/subsystems/web-server.md) — routes, matching order, and the config the server accepts. - [SPA dist server](../frontend-static/README.md) — the shipped owner of the fallback seat. -- [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) — why feature plugins own every route. +- [Web config-tree boot and transport layering](../../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md) — why feature plugins own every route. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-host-webserver) — every accepted config field and its source declaration. ----- diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md index 00524e4ecc..6ad4f549cd 100644 --- a/packages/host/webserver/README.zh.md +++ b/packages/host/webserver/README.zh.md @@ -88,7 +88,7 @@ index 启动输入分两层。`collectIndexInjections()` 收集一张全新的 - [HTTP 服务器子系统](../../../docs/subsystems/web-server.zh.md)——路由、匹配顺序与服务器接受的配置。 - [SPA dist 服务器](../frontend-static/README.zh.md)——回退席位的随附持有者。 -- [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md)——功能插件为何拥有每条路由。 +- [Web 配置树启动与传输分层](../../../.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md)——功能插件为何拥有每条路由。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-host-webserver)——每个受支持配置字段及其源声明。 ----- From 26f1eda42a6d1ed9e2f67c65eca67c2678e372c6 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:21:48 +0800 Subject: [PATCH 115/130] test(connection): allow non-Error rejection fixture --- packages/client/connection/tests/connection.client.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 9ac090e947..94038abf8e 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -144,6 +144,7 @@ describe('connection lifecycle', () => { { label: 'ends normally', fail: () => Promise.resolve() }, { label: 'rejects with a non-Error reason', + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- the non-Error rejection is the scenario under test fail: () => Promise.reject('fixture offline'), }, ])('retries when the generation source $label before reporting ready', async ({ fail }) => { From b0c44e54baff9207dd57d3e13ec0b1df483f3155 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:48:16 +0800 Subject: [PATCH 116/130] fix: build --- tsconfig.client.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.client.json b/tsconfig.client.json index 115c328537..ddeb2e84b7 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -100,6 +100,7 @@ { "path": "./packages/client/ui-renderer" }, { "path": "./packages/client/ui-session" }, { "path": "./packages/client/web" }, + { "path": "./packages/context/file-reference" }, { "path": "./apps/web" } ] } From 9fa87800a2e67238891e6d2a3cb5a078cbf1d6cf Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:58:43 +0800 Subject: [PATCH 117/130] fix(api): keep file-reference output in its project --- packages/api/session-controller/tsconfig.client.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 219893c980..030ed712d1 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -16,6 +16,7 @@ { "path": "../../attachment/attachment" }, { "path": "../../client/connection/tsconfig.client.json" }, { "path": "../../client/store" }, + { "path": "../../context/file-reference" }, { "path": "../../core/session" }, { "path": "../../jobs/jobs" }, { "path": "../../llm/llm" }, From 3ca9c7d4891760ba366123bf9f5d45ed7133c088 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:46:42 +0800 Subject: [PATCH 118/130] rename code-mode to ptc (PTC mode), except session-persistent vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename the tool-presentation transport from code-mode to ptc everywhere that is not written into session logs: the mode config value becomes 'ptc', the preset directory/id becomes ptc, the demo becomes demo:ptc, the dispatch waterfall becomes tools/ptc-dispatch-log (types PtcDispatch*), the prompt rule becomes tools:ptc-only, source/test files become ptc.ts etc., and prose says PTC mode / PTC 模式. The session-persistent vocabulary (durable events tool/code-dispatch*, logged plugin name tools-code-mode, sub-call id segment :code:) intentionally stays and moves in the stacked persistence PR, which is blocked until the SESSION_FORMAT_VERSION v0→v1 migration lands with it. run_code, its code parameter, CodeSdkLanguage, CodeRunFailedError, the dsh-code-runtime family, third-party codex names, and frozen archived notes keep their names. --- ...07-12-agent-scope-runtime-design.i18n.yaml | 4 +- .../2026-07-12-agent-scope-runtime-design.md | 20 +- ...026-07-12-agent-scope-runtime-design.zh.md | 20 +- ...19-cooperative-tool-cancellation.i18n.yaml | 4 +- ...026-07-19-cooperative-tool-cancellation.md | 4 +- ...-07-19-cooperative-tool-cancellation.zh.md | 4 +- ...0-canonical-tool-output-contract.i18n.yaml | 4 +- ...26-07-20-canonical-tool-output-contract.md | 6 +- ...07-20-canonical-tool-output-contract.zh.md | 6 +- ...runtime-portable-identifier-seam.i18n.yaml | 4 +- ...1-code-runtime-portable-identifier-seam.md | 4 +- ...ode-runtime-portable-identifier-seam.zh.md | 4 +- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 2 +- ...09-client-conversation-node-assembly.zh.md | 2 +- ...-08-09-headless-direct-core-entry-point.md | 4 +- ...-09-headless-direct-core-entry-point.zh.md | 4 +- ...6-08-25-rename-code-mode-to-ptc.i18n.yaml} | 6 +- .../2026-08-25-rename-code-mode-to-ptc.md | 37 ++++ .../2026-08-25-rename-code-mode-to-ptc.zh.md | 37 ++++ ...irst-party-prompt-section-orders.i18n.yaml | 4 +- ...parse-first-party-prompt-section-orders.md | 2 +- ...se-first-party-prompt-section-orders.zh.md | 2 +- ...026-08-07-ptc-executor-collapse.i18n.yaml} | 6 +- ...md => 2026-08-07-ptc-executor-collapse.md} | 14 +- ...=> 2026-08-07-ptc-executor-collapse.zh.md} | 12 +- ...ode.i18n.yaml => 2026-06-15-ptc.i18n.yaml} | 6 +- ...6-06-15-code-mode.md => 2026-06-15-ptc.md} | 50 ++--- ...5-code-mode.zh.md => 2026-06-15-ptc.zh.md} | 50 ++--- ...26-06-17-filesystem-tool-schemas.i18n.yaml | 4 +- .../2026-06-17-filesystem-tool-schemas.md | 2 +- .../2026-06-17-filesystem-tool-schemas.zh.md | 2 +- .../2026-06-24-workspace-context.i18n.yaml | 4 +- .../feature/2026-06-24-workspace-context.md | 2 +- .../2026-06-24-workspace-context.zh.md | 2 +- .../feature/2026-06-30-hook-bridges.i18n.yaml | 4 +- .../feature/2026-06-30-hook-bridges.md | 2 +- .../feature/2026-06-30-hook-bridges.zh.md | 2 +- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 10 +- .../2026-07-07-mcp-client-plugin.zh.md | 10 +- ...-10-parallel-tool-call-execution.i18n.yaml | 4 +- ...2026-07-10-parallel-tool-call-execution.md | 6 +- ...6-07-10-parallel-tool-call-execution.zh.md | 6 +- ...nt-persona-tool-filter-and-depth.i18n.yaml | 4 +- ...-subagent-persona-tool-filter-and-depth.md | 4 +- ...bagent-persona-tool-filter-and-depth.zh.md | 4 +- ...-20-code-mode-typed-tool-returns.i18n.yaml | 6 - ...26-07-20-dsh-cli-personal-config.i18n.yaml | 4 +- .../2026-07-20-dsh-cli-personal-config.md | 2 +- .../2026-07-20-dsh-cli-personal-config.zh.md | 2 +- ...26-07-20-ptc-typed-tool-returns.i18n.yaml} | 6 +- ...d => 2026-07-20-ptc-typed-tool-returns.md} | 24 +-- ...> 2026-07-20-ptc-typed-tool-returns.zh.md} | 26 +-- ...ge-input-and-durable-attachments.i18n.yaml | 4 +- ...dal-image-input-and-durable-attachments.md | 12 +- ...-image-input-and-durable-attachments.zh.md | 12 +- .../2026-07-26-code-dispatch-log-spill.md | 31 --- .../2026-07-26-code-dispatch-log-spill.zh.md | 31 --- ...7-26-code-mode-chat-subcall-rows.i18n.yaml | 6 - ...code-mode-live-parallel-dispatch.i18n.yaml | 6 - ...2026-07-26-ptc-chat-subcall-rows.i18n.yaml | 6 + ...md => 2026-07-26-ptc-chat-subcall-rows.md} | 14 +- ...=> 2026-07-26-ptc-chat-subcall-rows.zh.md} | 14 +- ...026-07-26-ptc-dispatch-log-spill.i18n.yaml | 6 + .../2026-07-26-ptc-dispatch-log-spill.md | 31 +++ .../2026-07-26-ptc-dispatch-log-spill.zh.md | 31 +++ ...07-26-ptc-dispatch-ui-foundation.i18n.yaml | 6 + ... 2026-07-26-ptc-dispatch-ui-foundation.md} | 14 +- ...26-07-26-ptc-dispatch-ui-foundation.zh.md} | 14 +- ...07-26-ptc-live-parallel-dispatch.i18n.yaml | 6 + ... 2026-07-26-ptc-live-parallel-dispatch.md} | 16 +- ...26-07-26-ptc-live-parallel-dispatch.zh.md} | 16 +- .../2026-07-28-web-terminal-card.i18n.yaml | 4 +- .../feature/2026-07-28-web-terminal-card.md | 2 +- .../2026-07-28-web-terminal-card.zh.md | 2 +- ...026-07-30-web-read-card-frontend.i18n.yaml | 4 +- .../2026-07-30-web-read-card-frontend.md | 2 +- .../2026-07-30-web-read-card-frontend.zh.md | 2 +- ...7-31-code-mode-language-dispatch.i18n.yaml | 6 - ...31-even-out-shipped-tool-rosters.i18n.yaml | 4 +- ...026-07-31-even-out-shipped-tool-rosters.md | 2 +- ...-07-31-even-out-shipped-tool-rosters.zh.md | 2 +- ...2026-07-31-ptc-language-dispatch.i18n.yaml | 6 + ...md => 2026-07-31-ptc-language-dispatch.md} | 16 +- ...=> 2026-07-31-ptc-language-dispatch.zh.md} | 16 +- .../2026-07-31-web-default-search.i18n.yaml | 4 +- .../feature/2026-07-31-web-default-search.md | 2 +- .../2026-07-31-web-default-search.zh.md | 4 +- ...8-05-per-agent-tool-presentation.i18n.yaml | 4 +- .../2026-08-05-per-agent-tool-presentation.md | 16 +- ...26-08-05-per-agent-tool-presentation.zh.md | 16 +- ...26-08-10-minimal-read-image-tool.i18n.yaml | 4 +- .../2026-08-10-minimal-read-image-tool.md | 2 +- .../2026-08-10-minimal-read-image-tool.zh.md | 2 +- ...26-web-syntax-highlighting-shiki.i18n.yaml | 4 +- ...026-07-26-web-syntax-highlighting-shiki.md | 2 +- ...-07-26-web-syntax-highlighting-shiki.zh.md | 2 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 4 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 2 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 2 +- ...lan-specific-collaboration-state.i18n.yaml | 4 +- ...07-22-plan-specific-collaboration-state.md | 4 +- ...22-plan-specific-collaboration-state.zh.md | 4 +- ...-remove-synthetic-log-only-turns.i18n.yaml | 4 +- ...6-07-28-remove-synthetic-log-only-turns.md | 2 +- ...7-28-remove-synthetic-log-only-turns.zh.md | 2 +- ...7-29-shared-base-config-overlays.i18n.yaml | 4 +- .../2026-07-29-shared-base-config-overlays.md | 6 +- ...26-07-29-shared-base-config-overlays.zh.md | 6 +- ...10-default-presets-single-editor.i18n.yaml | 4 +- ...026-08-10-default-presets-single-editor.md | 4 +- ...-08-10-default-presets-single-editor.zh.md | 4 +- ...r-local-profile-tests-and-guides.i18n.yaml | 4 +- ...24-owner-local-profile-tests-and-guides.md | 2 +- ...owner-local-profile-tests-and-guides.zh.md | 2 +- .../feature/2026-08-04-task-surface.i18n.yaml | 4 +- .../feature/2026-08-04-task-surface.md | 2 +- .../feature/2026-08-04-task-surface.zh.md | 2 +- ...-07-04-prune-dead-core-spine-api.i18n.yaml | 4 +- .../2026-07-04-prune-dead-core-spine-api.md | 2 +- ...2026-07-04-prune-dead-core-spine-api.zh.md | 2 +- ...-prune-unused-skill-registry-api.i18n.yaml | 4 +- ...6-07-12-prune-unused-skill-registry-api.md | 2 +- ...7-12-prune-unused-skill-registry-api.zh.md | 2 +- AGENTS.md | 2 +- THIRD_PARTY_NOTICES.md | 18 +- .../tests/{code-mode.e2e.ts => ptc.e2e.ts} | 44 ++-- apps/cli/tests/web-agent-presets.e2e.ts | 14 +- apps/cli/tests/windows-shell.spec.ts | 2 +- .../created.expected.md | 4 +- .../damaged.expected.md | 4 +- .../section.expected.md | 4 +- .../agent-preset-selection/menu.expected.md | 2 +- apps/web/tests/image-display.expected.e2e.ts | 2 +- ...ode-mode-round.e2e.ts => ptc-round.e2e.ts} | 18 +- apps/web/tests/scaffold.ts | 2 +- apps/web/tests/smoke-real.e2e.ts | 16 +- .../trajectory-image-display.expected.e2e.ts | 2 +- apps/web/tsconfig.json | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/cookbook/adding-a-tool.i18n.yaml | 4 +- docs/cookbook/adding-a-tool.md | 6 +- docs/cookbook/adding-a-tool.zh.md | 6 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 12 +- docs/subsystems/code-runtime.i18n.yaml | 4 +- docs/subsystems/code-runtime.md | 6 +- docs/subsystems/code-runtime.zh.md | 6 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 6 +- docs/tool-catalog.zh.md | 6 +- docs/tool-execution-pipeline.i18n.yaml | 4 +- docs/tool-execution-pipeline.md | 2 +- docs/tool-execution-pipeline.zh.md | 2 +- docs/user/develop/basic/tool.i18n.yaml | 4 +- docs/user/develop/basic/tool.md | 2 +- docs/user/develop/basic/tool.zh.md | 2 +- package.json | 2 +- packages/bundle/headless/cordis.patch.yml | 4 +- packages/bundle/web-app/cordis.patch.yml | 4 +- .../ui-agent-preset/src/client/locales.ts | 14 +- .../tests/locales.client.spec.ts | 2 +- .../tests/bootstrap.spec.ts | 2 +- .../code-runtime/code-runtime/src/types.ts | 2 +- .../core/agent-loop/tests/tool-calls.spec.ts | 4 +- .../core/agent-tool-presentation/package.json | 2 +- .../core/agent-tool-presentation/src/index.ts | 8 +- .../tests/agent-tool-presentation.spec.ts | 10 +- .../core/scope/src/scoped-events.generated.ts | 2 +- packages/core/scope/tests/invariant.spec.ts | 2 +- packages/core/system-prompt/src/index.ts | 2 +- packages/core/tools/src/index.ts | 82 ++++---- packages/core/tools/src/json-schema.ts | 2 +- .../core/tools/src/{code-mode.ts => ptc.ts} | 16 +- packages/core/tools/src/py-types.ts | 14 +- packages/core/tools/src/ts-types.ts | 6 +- packages/core/tools/src/types.ts | 13 +- packages/core/tools/tests/invariant.spec.ts | 6 +- .../tests/{code-mode.spec.ts => ptc.spec.ts} | 194 +++++++++--------- packages/core/tools/tests/py-types.spec.ts | 8 +- packages/fs/tool-fs/tests/read-image.spec.ts | 10 +- packages/mcp/mcp-client/src/tools.ts | 2 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 22 +- .../agent-presets/presets/code/preset.yml | 3 - .../editing-cordis-compositions/SKILL.md | 2 +- .../presets/{code => ptc}/agent.cordis.yml | 6 +- .../agent-presets/presets/ptc/preset.yml | 3 + .../agent-presets/tests/shipped-root.spec.ts | 4 +- packages/spill/spill-policy/src/index.ts | 6 +- .../spill-policy/tests/spill-policy.spec.ts | 12 +- .../src/structured.ts | 6 +- .../tests/structured.spec.ts | 10 +- .../tool-terminal/tests/tools.spec.ts | 2 +- scripts/{demo-code-mode.mjs => demo-ptc.mjs} | 6 +- scripts/gen-cordis-catalog.ts | 2 +- scripts/gen-doc-graphs.ts | 6 +- scripts/gen-tool-catalog.ts | 6 +- scripts/type-equiv.manifest.json | 2 +- .../verify-application-entrypoints.spec.ts | 8 +- scripts/verify-application-entrypoints.ts | 2 +- .../verify-package-readme-model-experience.ts | 6 +- .../session/code-mode-read-image/snapshot.yml | 12 -- .../code-mode-workspace-context/snapshot.yml | 12 -- .../session/cordis-inspect-jsdoc/cordis.yml | 2 +- .../cordis.snapshot.yml | 4 +- .../cordis.yml | 4 +- .../session.jsonl | 0 snapshots/session/ptc-read-image/snapshot.yml | 12 ++ .../system-prompt.expected.md | 0 .../workspace.expected/red.png | Bin .../cordis.snapshot.yml | 4 +- .../{code-mode-turn => ptc-turn}/cordis.yml | 4 +- .../session.jsonl | 0 .../{code-mode-turn => ptc-turn}/snapshot.yml | 6 +- .../system-prompt.expected.md | 0 .../tool-schemas.expected.json | 0 .../cordis.snapshot.yml | 6 +- .../cordis.yml | 4 +- .../replay.override.json | 0 .../session.jsonl | 0 .../ptc-workspace-context/snapshot.yml | 12 ++ .../workspace/AGENTS.md | 0 .../workspace/nested/AGENTS.md | 0 .../workspace/nested/task.txt | 0 snapshots/session/skill-load/session.jsonl | 2 +- snapshots/web/code-mode-round/snapshot.yml | 8 - .../session.jsonl | 0 snapshots/web/ptc-round/snapshot.yml | 8 + .../system-prompt.expected.md | 0 .../tool-schemas.expected.json | 0 .../ui.expected.md | 0 snapshots/web/skill-tool-row/ui.expected.md | 2 +- tsconfig.host.json | 2 +- 246 files changed, 970 insertions(+), 895 deletions(-) rename .agents/notes/implemented/{bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml => architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml} (57%) create mode 100644 .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md create mode 100644 .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md rename .agents/notes/implemented/{feature/2026-07-26-code-dispatch-log-spill.i18n.yaml => bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml} (58%) rename .agents/notes/implemented/bug-fix/{2026-08-07-code-mode-executor-collapse.md => 2026-08-07-ptc-executor-collapse.md} (66%) rename .agents/notes/implemented/bug-fix/{2026-08-07-code-mode-executor-collapse.zh.md => 2026-08-07-ptc-executor-collapse.zh.md} (69%) rename .agents/notes/implemented/feature/{2026-06-15-code-mode.i18n.yaml => 2026-06-15-ptc.i18n.yaml} (63%) rename .agents/notes/implemented/feature/{2026-06-15-code-mode.md => 2026-06-15-ptc.md} (75%) rename .agents/notes/implemented/feature/{2026-06-15-code-mode.zh.md => 2026-06-15-ptc.zh.md} (75%) delete mode 100644 .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml rename .agents/notes/implemented/feature/{2026-07-26-code-dispatch-ui-foundation.i18n.yaml => 2026-07-20-ptc-typed-tool-returns.i18n.yaml} (57%) rename .agents/notes/implemented/feature/{2026-07-20-code-mode-typed-tool-returns.md => 2026-07-20-ptc-typed-tool-returns.md} (73%) rename .agents/notes/implemented/feature/{2026-07-20-code-mode-typed-tool-returns.zh.md => 2026-07-20-ptc-typed-tool-returns.zh.md} (71%) delete mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md delete mode 100644 .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml rename .agents/notes/implemented/feature/{2026-07-26-code-mode-chat-subcall-rows.md => 2026-07-26-ptc-chat-subcall-rows.md} (53%) rename .agents/notes/implemented/feature/{2026-07-26-code-mode-chat-subcall-rows.zh.md => 2026-07-26-ptc-chat-subcall-rows.zh.md} (51%) create mode 100644 .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md create mode 100644 .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml rename .agents/notes/implemented/feature/{2026-07-26-code-dispatch-ui-foundation.md => 2026-07-26-ptc-dispatch-ui-foundation.md} (55%) rename .agents/notes/implemented/feature/{2026-07-26-code-dispatch-ui-foundation.zh.md => 2026-07-26-ptc-dispatch-ui-foundation.zh.md} (57%) create mode 100644 .agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml rename .agents/notes/implemented/feature/{2026-07-26-code-mode-live-parallel-dispatch.md => 2026-07-26-ptc-live-parallel-dispatch.md} (73%) rename .agents/notes/implemented/feature/{2026-07-26-code-mode-live-parallel-dispatch.zh.md => 2026-07-26-ptc-live-parallel-dispatch.zh.md} (73%) delete mode 100644 .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.i18n.yaml rename .agents/notes/implemented/feature/{2026-07-31-code-mode-language-dispatch.md => 2026-07-31-ptc-language-dispatch.md} (88%) rename .agents/notes/implemented/feature/{2026-07-31-code-mode-language-dispatch.zh.md => 2026-07-31-ptc-language-dispatch.zh.md} (88%) rename apps/cli/tests/profiles/headless/tests/{code-mode.e2e.ts => ptc.e2e.ts} (91%) rename apps/web/tests/{code-mode-round.e2e.ts => ptc-round.e2e.ts} (90%) rename packages/core/tools/src/{code-mode.ts => ptc.ts} (98%) rename packages/core/tools/tests/{code-mode.spec.ts => ptc.spec.ts} (92%) delete mode 100644 packages/preset/agent-presets/presets/code/preset.yml rename packages/preset/agent-presets/presets/{code => ptc}/agent.cordis.yml (98%) create mode 100644 packages/preset/agent-presets/presets/ptc/preset.yml rename scripts/{demo-code-mode.mjs => demo-ptc.mjs} (58%) delete mode 100644 snapshots/session/code-mode-read-image/snapshot.yml delete mode 100644 snapshots/session/code-mode-workspace-context/snapshot.yml rename snapshots/session/{code-mode-read-image => ptc-read-image}/cordis.snapshot.yml (94%) rename snapshots/session/{code-mode-read-image => ptc-read-image}/cordis.yml (91%) rename snapshots/session/{code-mode-read-image => ptc-read-image}/session.jsonl (100%) create mode 100644 snapshots/session/ptc-read-image/snapshot.yml rename snapshots/session/{code-mode-read-image => ptc-read-image}/system-prompt.expected.md (100%) rename snapshots/session/{code-mode-read-image => ptc-read-image}/workspace.expected/red.png (100%) rename snapshots/session/{code-mode-turn => ptc-turn}/cordis.snapshot.yml (93%) rename snapshots/session/{code-mode-turn => ptc-turn}/cordis.yml (91%) rename snapshots/session/{code-mode-turn => ptc-turn}/session.jsonl (100%) rename snapshots/session/{code-mode-turn => ptc-turn}/snapshot.yml (53%) rename snapshots/session/{code-mode-turn => ptc-turn}/system-prompt.expected.md (100%) rename snapshots/session/{code-mode-turn => ptc-turn}/tool-schemas.expected.json (100%) rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/cordis.snapshot.yml (87%) rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/cordis.yml (89%) rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/replay.override.json (100%) rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/session.jsonl (100%) create mode 100644 snapshots/session/ptc-workspace-context/snapshot.yml rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/workspace/AGENTS.md (100%) rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/workspace/nested/AGENTS.md (100%) rename snapshots/session/{code-mode-workspace-context => ptc-workspace-context}/workspace/nested/task.txt (100%) delete mode 100644 snapshots/web/code-mode-round/snapshot.yml rename snapshots/web/{code-mode-round => ptc-round}/session.jsonl (100%) create mode 100644 snapshots/web/ptc-round/snapshot.yml rename snapshots/web/{code-mode-round => ptc-round}/system-prompt.expected.md (100%) rename snapshots/web/{code-mode-round => ptc-round}/tool-schemas.expected.json (100%) rename snapshots/web/{code-mode-round => ptc-round}/ui.expected.md (100%) diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 7d8d2db1cf..241dcf71db 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: 9d70b8048b1d2bb50290d34158d9deb329d5e15e -2026-07-12-agent-scope-runtime-design.zh.md: 278bcede47fee9f67d3d2d2d7135e5357c120161 +2026-07-12-agent-scope-runtime-design.md: 7aebac35d1a5477f0b1d3857e983680ea38bd9f5 +2026-07-12-agent-scope-runtime-design.zh.md: c636bf6f51c58aa5d9f1e3b6f9988b4f4ab5b86c diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index 9d70b8048b..7aebac35d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -74,7 +74,7 @@ The receiver is a small carrier rather than a transparent proxy for the domain o Scope-aware registries use `ScopedLayers` to own one eager global aggregate and lazily created identity-keyed aggregates. A read resolves the global layer and at most one exact local layer; it never creates state or traverses parentage. Registration visibility and Cordis effect ownership derive from the same context, and reclamation waits until the concrete layer's complete aggregate is empty ([decision](2026-07-12-scoped-layers-store.md)). -Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved Code Mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view. +Each service retains its domain rule. Named command and prompt views use the shared insertion-ordered shadow merge; tools keep a richer resolver because restrictions filter globals before local tools are added and the reserved PTC mode transport is inserted separately. Prompt variables and tool guards retain live iteration, while tool-provider membership is materialized per assembly. Scope supplies storage lifecycle and named shadowing, not a universal registry view. ### Fused dispatch helpers prevent subject drift @@ -206,7 +206,7 @@ Tool presentation and execution share one private resolver. Prompt assembly rema ### One resolver defines the tool view -The private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, Code Mode SDK generation, and restriction validation all use that resolver or its pre-restriction global-name view. +The private resolver applies the current presentation mode, live global restrictions, exact local overlay, and local shadowing. Schemas, lookup, execution, PTC mode SDK generation, and restriction validation all use that resolver or its pre-restriction global-name view. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) owns the user-visible allow/deny semantics. The implementation requirement is agreement: a filtered-away global cannot remain executable through a different lookup path, and a locally shadowed definition is the same definition presented and executed. @@ -214,11 +214,11 @@ The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-pe ### Tool execution owns identity and boundary materialization -The registry assigns every execution a fresh branded `Symbol` token. Nested Code Mode calls carry the outer token as `parent`, so structured output can correlate an inner capture with its enclosing `run_code` result by identity. +The registry assigns every execution a fresh branded `Symbol` token. Nested PTC mode calls carry the outer token as `parent`, so structured output can correlate an inner capture with its enclosing `run_code` result by identity. A fresh registry-assigned Symbol provides collision-free execution identity without a WeakSet membership registry. Callers cannot supply the execution's own token through `ToolExecutionInput`; they only receive the pipeline-owned `ToolExecution` after the registry creates it. This is a trusted typed contract, not a runtime defense against arbitrary casts or JavaScript callers. -Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and Code Mode nesting remain explicit relational checks. +Arguments are materialized once where model/tool JSON enters the pipeline. Pre-, around-, and post-execute listeners operate on the typed execution and decisions. Call ID correlation, approval, monotonic guards, and PTC mode nesting remain explicit relational checks. After post-execute or outer pipeline normalization, the registry losslessly snapshots the candidate result, converting a snapshot failure into an ordinary error, invokes the call's snapshotted optional `ToolDefinition.finalizeContent` callback, then materializes and freezes the accepted final result once. The callback may replace only content, so structured error identity, contexts, and metadata remain registry-owned even when a tool enforces a last-mile result bound. Every synchronous `tools/result` observer receives that exact committed object, and observer failures are contained individually. An outer pipeline or candidate-snapshot failure is normalized before final content, so observers can discard staged work against the same authoritative boundary. @@ -226,9 +226,9 @@ After post-execute or outer pipeline normalization, the registry losslessly snap SystemPrompt first resolves the global-plus-agent sections, variables, and tool providers into a deterministic registry contribution. The scope-filtered `system-prompt/assemble` waterfall may then reorder, replace, add, or remove any section, variable, or schema. Its returned assembly is authoritative; there is no later restoration pass and no finality metadata on ordinary prompt sections, tool definitions, or provider results. -This is a trusted same-process extension point, not an authority boundary. A listener that changes Code Mode's `run_code` schema or `tools:sdk` instructions, or a structured child's capture schema or instruction, owns preserving a coherent protocol in the assembly it returns. ToolRuntime still reserves `run_code` against ordinary tool registration and restriction because those are registry invariants, but assembly middleware remains free to transform the final model-visible surface. +This is a trusted same-process extension point, not an authority boundary. A listener that changes PTC mode's `run_code` schema or `tools:sdk` instructions, or a structured child's capture schema or instruction, owns preserving a coherent protocol in the assembly it returns. ToolRuntime still reserves `run_code` against ordinary tool registration and restriction because those are registry invariants, but assembly middleware remains free to transform the final model-visible surface. -Scope solves the real isolation problem directly. Structured-output contributions register in the child's exact scope, while Code Mode derives its transport and SDK from the same resolved tool view. A second named-protection system would need another ownership and collision rule across arbitrary schema providers—including providers that intentionally contribute duplicate names—without creating a new trust boundary. +Scope solves the real isolation problem directly. Structured-output contributions register in the child's exact scope, while PTC mode derives its transport and SDK from the same resolved tool view. A second named-protection system would need another ownership and collision rule across arbitrary schema providers—including providers that intentionally contribute duplicate names—without creating a new trust boundary. ### Structured output commits only authoritative outcomes @@ -236,11 +236,11 @@ Structured output combines child-scoped composition with a two-phase execution c For a native call, the observer deletes the stage and commits its value only when that exact execution's final result succeeds. A post-execute block or outer pipeline failure therefore cannot leave a captured value behind. -For a Code Mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. +For a PTC mode SDK call, the inner successful result records `{ parentToken, value }` rather than committing. The observer waits for the `run_code` execution whose token matches `parentToken` and commits only if that outer final result also succeeds. Program failure, runtime abort, or outer post-policy denial discards the pending value. Once a value is pending or committed, a scoped monotonic guard denies later tool calls. The successful structured-output execution calls `exec.concludeTurn()`, so its own immutable result carries `concludesTurn: true` and the loop ends the tool loop at that step. A schema-validation failure remains an ordinary `INVALID_ARGS` tool error and leaves the child able to retry within the same turn. -Pure Code Mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. +Pure PTC mode's registry contribution omits `structured_output` from native wire schemas and exposes it through the generated SDK. The assembly waterfall may deliberately change that presentation; execution still validates against the child-scoped definition, and the listener owns the consistency of any alternate model-visible route it creates. ### Three execution boundaries are deliberately one-way @@ -332,7 +332,7 @@ The plugin does not police trusted setup by scanning registries or reject prompt The event catalog, service catalog, producer/consumer matrix, configuration catalog, module graph, tool catalog, type-equivalence blocks, and scoped-event resolver map are generated or freshness-gated from source. The [TypeScript semantic-gates Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md) owns Program construction, semantic event discovery, and resolver-generation rules. -Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and Code Mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. +Behavioral tests pin scoped routing and disposal, final-entry collision cleanup, publication rollback, ordered quiescence, durable pre/post-commit behavior, live tool filtering across presentation and execution, cooperative prompt assembly, structured-output commit in native and PTC mode, async subagent startup and signal cancellation, worker terminal arbitration, ACP settlement, and process teardown. ## Alternatives considered @@ -385,7 +385,7 @@ The implementation is smaller and its proof follows the same shape as its owners Scope-aware services still maintain global and identity-keyed maps, and operations must carry their real agent explicitly. Async create/resume and subagent start require callers to await ownership transfer and dispose returned handles. -A trusted `system-prompt/assemble` listener can remove or replace Code Mode and structured-output protocol pieces. This is deliberate: the listener owns final composition and must preserve any protocol the deployment expects to remain usable. +A trusted `system-prompt/assemble` listener can remove or replace PTC mode and structured-output protocol pieces. This is deliberate: the listener owns final composition and must preserve any protocol the deployment expects to remain usable. The design trusts typed plugins in the same process. It does not defend against arbitrary casts, stateful getters, mutation that violates readonly contracts, or a plugin deliberately using ambient service access outside the supported composition API. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index 278bcede47..c636bf6f51 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -76,7 +76,7 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.zh.md))。 -每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 +每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 PTC mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 ### 融合 dispatch 辅助函数防止主体漂移 @@ -210,7 +210,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 ### 一个解析器定义工具视图 -私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 +私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、PTC mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 @@ -218,11 +218,11 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 ### 工具执行拥有标识和边界物化 -注册表为每次执行分配一个新的带品牌的 `Symbol` token。嵌套的 Code Mode 调用将外层 token 作为 `parent` 携带,因此结构化输出可以通过标识将内层捕获与其外层 `run_code` 结果关联。 +注册表为每次执行分配一个新的带品牌的 `Symbol` token。嵌套的 PTC mode 调用将外层 token 作为 `parent` 携带,因此结构化输出可以通过标识将内层捕获与其外层 `run_code` 结果关联。 注册表分配的新 Symbol 提供无碰撞的执行标识,无需 WeakSet 成员注册表。调用方无法通过 `ToolExecutionInput` 提供执行自身的 token;它们仅在注册表创建后接收流水线拥有的 `ToolExecution`。这是一个可信的类型化约定,而非针对任意强制转换或 JavaScript 调用方的运行时防御。 -参数在模型/工具 JSON 进入流水线时一次性物化。Pre-、around- 和 post-execute 监听器操作类型化的 execution 和决策。Call ID 关联、审批、单调守卫和 Code Mode 嵌套仍然是显式的关系检查。 +参数在模型/工具 JSON 进入流水线时一次性物化。Pre-、around- 和 post-execute 监听器操作类型化的 execution 和决策。Call ID 关联、审批、单调守卫和 PTC mode 嵌套仍然是显式的关系检查。 在 post-execute 或外层流水线完成规范化后,注册表先为候选结果创建无损快照,并将快照失败转为普通错误;随后调用在本次调用创建时已快照的可选 `ToolDefinition.finalizeContent` 回调,最后一次性物化并冻结被接受的最终结果。该回调只能替换内容,因此即使工具强制最后一道结果上限,结构化错误标识、上下文与元数据仍由注册表拥有。每个同步的 `tools/result` 观察者接收该确切的已提交对象,观察者失败被逐个隔离。外层流水线失败或候选快照失败会在最终内容处理之前被规范化,因此观察者可以丢弃针对同一权威边界的暂存工作。 @@ -230,9 +230,9 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为确定性的注册表贡献。作用域过滤的 `system-prompt/assemble` waterfall 随后可以重排、替换、添加或移除任何段、变量或 schema。其返回的组装结果即为权威;没有后续的恢复步骤,普通提示词段、工具定义或提供方结果上也没有终态元数据。 -这是一个可信的同进程扩展点,而非权限边界。修改 Code Mode 的 `run_code` schema 或 `tools:sdk` 指令,或结构化子级的捕获 schema 或指令的监听器,有责任在其返回的组装中保持协议的一致性。ToolRuntime 仍然保留 `run_code` 不受普通工具注册和限制影响,因为那些是注册表不变式,但 assembly 中间件仍然可以自由变换最终的模型可见表面。 +这是一个可信的同进程扩展点,而非权限边界。修改 PTC mode 的 `run_code` schema 或 `tools:sdk` 指令,或结构化子级的捕获 schema 或指令的监听器,有责任在其返回的组装中保持协议的一致性。ToolRuntime 仍然保留 `run_code` 不受普通工具注册和限制影响,因为那些是注册表不变式,但 assembly 中间件仍然可以自由变换最终的模型可见表面。 -Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 +Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 PTC mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 @@ -242,11 +242,11 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 对于原生调用,观察者仅在该确切执行的最终结果成功时才删除暂存并提交其值。因此 post-execute 阻止或外层流水线失败不会留下已捕获的值。 -对于 Code Mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 +对于 PTC mode SDK 调用,内层成功结果记录 `{ parentToken, value }` 而非提交。观察者等待 token 匹配 `parentToken` 的 `run_code` 执行,仅在该外层最终结果也成功时才提交。程序失败、运行时中止或外层 post-policy 拒绝会丢弃待定值。 一旦值处于待定或已提交状态,作用域单调守卫拒绝后续工具调用。成功的结构化输出执行会调用 `exec.concludeTurn()`,因此其自身不可变结果携带 `concludesTurn: true`,循环在该步骤结束工具循环。Schema 验证失败仍然是普通的 `INVALID_ARGS` 工具错误,子级可以在同一轮次内重试。 -纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 +纯 PTC mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 @@ -342,7 +342,7 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.zh.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 -行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 +行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 PTC mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 @@ -395,7 +395,7 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化 作用域感知服务仍然维护全局和按标识键索引的映射,操作必须显式携带其真实 agent。异步创建/恢复和 subagent start 要求调用方等待所有权转移并 dispose 返回的句柄。 -可信的 `system-prompt/assemble` 监听器可以移除或替换 Code Mode 和结构化输出协议片段。这是有意为之:监听器拥有最终组合,必须保持部署期望仍可用的任何协议。 +可信的 `system-prompt/assemble` 监听器可以移除或替换 PTC mode 和结构化输出协议片段。这是有意为之:监听器拥有最终组合,必须保持部署期望仍可用的任何协议。 该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 约定的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index 29807cfb7c..41d4664d7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md -2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8 -2026-07-19-cooperative-tool-cancellation.zh.md: ec35734eef91c5c774d1be814b221e9fdb8f65fa +2026-07-19-cooperative-tool-cancellation.md: 5ca2b44b24a4af189df27c6f724021a7bf290e2b +2026-07-19-cooperative-tool-cancellation.zh.md: d6d49fb44c5d5629729a8b5b4bad07ea9b2f7ee9 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md index 781202688a..5ca2b44b24 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md @@ -16,7 +16,7 @@ Cancellation can arrive before policy, during approval, inside an around-dispatc `ToolExecutionInput.signal` is a required readonly `AbortSignal`. `ToolExecution.signal` and `ToolRunContext.signal` are therefore required and readonly as well. Every typed caller supplies the signal it owns; the registry provides no overload, default controller, never-abort sentinel, or convenience execution path. -`ToolDefinition.execute(args, exec)` keeps its existing signature. `defineTool()` contextually types `exec.signal` as a required `AbortSignal`, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested Code Mode dispatches pass their current operation signal explicitly. +`ToolDefinition.execute(args, exec)` keeps its existing signature. `defineTool()` contextually types `exec.signal` as a required `AbortSignal`, so every registered TypeScript tool can observe or forward cancellation without a cast. First-party direct callers and nested PTC mode dispatches pass their current operation signal explicitly. The registry trusts this typed same-process contract. It does not perform runtime `AbortSignal` validation or add hostile-input tests for an omitted or malformed signal. Validation remains at parser/config, model/tool JSON, durable/file, worker, process, and wire boundaries; untyped JavaScript that violates the TypeScript interface has no compatibility contract. @@ -46,7 +46,7 @@ This decision requires cancellation at the tool invocation boundary only. Making ## Verification -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) proves the required exact signal types, readonly observer and tool views, mutable-but-required around-dispatch view, and `defineTool()` inference. [`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) covers pre-aborted materialization, phase skipping, policy and wrapper races, body invocation classification, caller-signal fusion, error precedence, context retention, and quiescent drainage. [`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) and [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) cover balanced durable results for undispatched siblings. [`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) and first-party integration suites cover explicit forwarding, while [`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) preserves timeout ownership. No registry test can prove that arbitrary third-party same-process code observes the signal or stops in bounded time. Capability tests continue to prove cancellation and quiescence at the boundary that owns each side effect. diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index ec35734eef..d6d49fb44c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -16,7 +16,7 @@ Status: implemented `ToolExecutionInput.signal` 是必填且只读的 `AbortSignal`,因此 `ToolExecution.signal` 和 `ToolRunContext.signal` 也都是必填且只读。每个类型化调用方显式提供自己持有的信号;注册表不提供重载、默认控制器、永不中止哨兵或便捷执行路径。 -`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 Code Mode 嵌套调度都会显式传入当前操作的信号。 +`ToolDefinition.execute(args, exec)` 保持现有签名。`defineTool()` 会把 `exec.signal` 上下文推断为必填的 `AbortSignal`,因此每个已注册的 TypeScript 工具都能在无需类型断言的情况下观察或转发取消。所有第一方直接调用方和 PTC mode 嵌套调度都会显式传入当前操作的信号。 注册表信任这份类型化同进程约定。它不在运行时校验 `AbortSignal`,也不为缺失或畸形信号添加敌意输入测试。校验仍位于解析器与配置、模型与工具 JSON、持久化与文件、worker、进程和协议边界;违反 TypeScript 接口的无类型 JavaScript 不享有兼容性约定。 @@ -46,7 +46,7 @@ Status: implemented ## 验证 -[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`code-mode.spec.ts`](../../../../packages/core/tools/tests/code-mode.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 +[`execution-signal-types.spec.ts`](../../../../packages/core/tools/tests/execution-signal-types.spec.ts) 证明必填的精确信号类型、观察者与工具的只读视图、环绕调度可替换但不可删除的视图,以及 `defineTool()` 推断。[`tools.spec.ts`](../../../../packages/core/tools/tests/tools.spec.ts) 覆盖进入时已中止的物化与阶段跳过、策略和包装层竞态、工具主体调用分类、调用方信号融合、错误优先级、上下文保留和完全停稳。[`tool-calls.spec.ts`](../../../../packages/core/agent-loop/tests/tool-calls.spec.ts) 与 [`contract-regressions.spec.ts`](../../../../packages/core/agent-loop/tests/contract-regressions.spec.ts) 覆盖为未调度的同批调用补齐持久化结果。[`ptc.spec.ts`](../../../../packages/core/tools/tests/ptc.spec.ts) 和第一方集成测试覆盖显式转发,[`timeout-policy.spec.ts`](../../../../packages/guard/timeout-policy/tests/timeout-policy.spec.ts) 保持超时归属。 任何注册表测试都无法证明任意第三方同进程代码会观察信号或在有界时间内停止。各能力的测试仍需在拥有相应副作用的边界证明取消与完全停稳。 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 84a51ba792..88da18837a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md -2026-07-20-canonical-tool-output-contract.md: 0b3bd788fd1ab63aa6929827ef82e5ba732e5324 -2026-07-20-canonical-tool-output-contract.zh.md: bbcd519de8d02df14be931b44f8dc44fc06b8f6a +2026-07-20-canonical-tool-output-contract.md: 95f313732c78f4f9acf2a08ce492968e7942e2f2 +2026-07-20-canonical-tool-output-contract.zh.md: 39b7ea88df3c290bd8095fa05bf05497be262d74 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index 0b3bd788fd..95f313732c 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -6,7 +6,7 @@ English | [中文](2026-07-20-canonical-tool-output-contract.zh.md) ## Problem -Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: Code Mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary. +Tool bodies previously authored model-facing `ContentBlock[]` directly, optionally wrapping it with opaque `meta`. Native function calling therefore had a usable human projection, but a programmatic caller had no stable domain value: PTC mode flattened the blocks back into a string, dynamic tools repeated the content shape, and policy could replace presentation without any way to distinguish that change from replacing the operation's result. Several capability seams already returned richer provider values only to discard them at their model-facing tool boundary. The durable session contract made that presentation authoritative for replay, but persisting every rich intermediate value would enlarge logs, expose implementation data to compaction and migration, and incorrectly turn an execution-local API into session format. The foundation instead needs one typed value during execution and an explicit projection into the existing durable/model-facing content. @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value. -Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; Code Mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. +Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/ptc-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. The first-party tools preserve their existing Native text while returning domain DTOs: @@ -66,7 +66,7 @@ MCP bridges preserve protocol blocks through `McpResult<{...}> = { content: Json ## Alternatives considered -- **Return rendered text to Code Mode:** rejected because callers would continue scraping prose for job ids, mount ids, paths, and structured provider results. +- **Return rendered text to PTC mode:** rejected because callers would continue scraping prose for job ids, mount ids, paths, and structured provider results. - **Persist canonical values on `tool/result`:** rejected because nested execution values are not model history, need not survive replay, and would create a session-format and storage commitment unrelated to Native reconstruction. - **Let tools return both value and content:** rejected because two author-owned results can disagree and policy cannot state which one is authoritative. The renderer makes presentation a deterministic projection of the validated value. - **Treat content replacement as value redaction:** rejected because presentation and programmatic access are different consumers; hiding only the former would create a false security boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index bbcd519de8..39b7ea88df 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:Code Mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 +工具主体过去直接编写面向模型的 `ContentBlock[]`,并可选择将其与不透明的 `meta` 包装在一起。因此,Native 模式的 Function Calling(函数调用)虽然拥有可供人阅读的投影,但程序化调用方没有稳定的领域值:PTC mode 会将内容块重新展平为字符串,动态工具会重复定义内容形态,策略也可以替换展示内容,却无法区分这项变更究竟是替换展示,还是替换操作结果。多个能力 seam 已经返回了信息更丰富的提供方值,却又在面向模型的工具边界丢弃这些值。 持久会话约定将这份展示内容视为回放时的权威来源,但如果持久化每一个信息丰富的中间值,就会扩大日志、使实现数据进入压缩(compaction)和迁移流程,还会错误地把执行期本地 API 变成会话格式的一部分。因此,系统底层需要在执行期间保留一个类型化值,并显式将其投影为现有的持久化内容和模型可见内容。 @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。 -规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;Code Mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 +规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/ptc-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 第一方工具在保持现有 Native 文本不变的同时返回领域 DTO: @@ -66,7 +66,7 @@ MCP 桥接层通过 `McpResult<{...}> = { content: JsonValue[]; structuredConten ## 备选方案 -- **向 Code Mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 job id、挂载 id、路径和结构化提供方结果。 +- **向 PTC mode 返回渲染后的文本:**不予采纳。调用方仍需从自然语言中提取 job id、挂载 id、路径和结构化提供方结果。 - **在 `tool/result` 上持久化规范值:**不予采纳。嵌套执行值不属于模型历史记录,无需在回放后继续存在;持久化还会引入与 Native 重建无关的会话格式和存储承诺。 - **允许工具同时返回值和内容:**不予采纳。由作者分别维护的两份结果可能互相矛盾,策略也无法说明哪一份才是权威结果。渲染器会根据已校验值确定性地产生展示。 - **将内容替换视为值脱敏:**不予采纳。展示内容和程序化访问面向不同消费方;只隐藏前者会制造虚假的安全边界。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml index 7d56d900be..dd6e455025 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md -2026-07-31-code-runtime-portable-identifier-seam.md: 6d75f0b0872a5d4b92403f597f5551276e2d7c9f -2026-07-31-code-runtime-portable-identifier-seam.zh.md: 98f4665f9c9964701cdeb108e411ae08121af9e0 +2026-07-31-code-runtime-portable-identifier-seam.md: 2011b0f6bc8209e628227ddf486aa1143a63688a +2026-07-31-code-runtime-portable-identifier-seam.zh.md: 36af33366d004fedc6b1077a937d6519de743638 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md index 6d75f0b087..2011b0f6bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -6,7 +6,7 @@ English | [中文](2026-07-31-code-runtime-portable-identifier-seam.zh.md) ## Problem -The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a Code Mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker-thread`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract. +The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a PTC mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker-thread`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract. A second backend written against a different language (CPython) would either re-declare its own rules — letting `lambda` pass the worker and fail Python, or `$tools` pass the worker and fail every non-JS backend — or import the worker's, inverting the dependency so a Service Provider reached into a sibling Service Provider. Neither keeps the portability promise real: it would hold only for the backend a caller happened to test against. @@ -25,7 +25,7 @@ The constants live in the Service Definition even though the worker is the only ## Scope -This decision delivers only the Service Definition extension and the worker's adoption of it. The `py-types` renderer and Code Mode language dispatch are owned by the [language-dispatch note](../feature/2026-07-31-code-mode-language-dispatch.md); a Python backend does not exist yet. The Service Definition README keeps its worker-only wording for that reason: linking to a `dsh-code-runtime-python` README that does not exist would break the dead-link gate. +This decision delivers only the Service Definition extension and the worker's adoption of it. The `py-types` renderer and PTC mode language dispatch are owned by the [language-dispatch note](../feature/2026-07-31-ptc-language-dispatch.md); a Python backend does not exist yet. The Service Definition README keeps its worker-only wording for that reason: linking to a `dsh-code-runtime-python` README that does not exist would break the dead-link gate. `RESERVED_BINDING_GLOBALS` encodes the Python bootstrap's concrete design ahead of the backend itself: it seeds exactly `__builtins__`/`__name__` and wraps the program under `__dsh_main__`. A Python backend that seeds any additional module global (`__doc__`, `__loader__`, `__spec__`, `__file__`, `__package__`, …) MUST widen this set in the same change, exactly as adding a language widens `PORTABLE_RESERVED_WORDS` — a name the bootstrap seeds but the set omits is the portability split this contract exists to prevent. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md index 98f4665f9c..36af33366d 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 Code Mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker-thread` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$` 的 `IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性约定。 +code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 PTC mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker-thread` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$` 的 `IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性约定。 一个针对不同语言(CPython)编写的第二后端,要么重新声明自己的规则——让 `lambda` 通过 worker 却在 Python 上失败,或让 `$tools` 通过 worker 却在每个非 JS 后端上失败——要么导入 worker 的规则,从而反转依赖,使一个 Service Provider 伸手进入另一个兄弟 Service Provider。二者都无法让可移植承诺成真:它只对调用方恰好测试过的那个后端成立。 @@ -25,7 +25,7 @@ Service Definition 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0 ## Scope -本决策只交付 Service Definition 扩展与 worker 对它的采用。`py-types` 渲染器与 Code Mode 的语言分发归[语言分发 note](../feature/2026-07-31-code-mode-language-dispatch.zh.md) 所有;Python 后端尚不存在。Service Definition README 因此保留仅描述 worker 的措辞:链接到一个不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 +本决策只交付 Service Definition 扩展与 worker 对它的采用。`py-types` 渲染器与 PTC mode 的语言分发归[语言分发 note](../feature/2026-07-31-ptc-language-dispatch.zh.md) 所有;Python 后端尚不存在。Service Definition README 因此保留仅描述 worker 的措辞:链接到一个不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 `RESERVED_BINDING_GLOBALS` 先于后端本身编码了 Python bootstrap 的具体设计:它恰好 seed `__builtins__`/`__name__`,并把程序包装在 `__dsh_main__` 之下。任何 seed 额外模块 global(`__doc__`、`__loader__`、`__spec__`、`__file__`、`__package__` 等)的 Python 后端必须在同一改动中扩宽此集合,正如新增一门语言即扩宽 `PORTABLE_RESERVED_WORDS`——bootstrap 会 seed 却不在集合中的名称,正是本约定要防止的可移植性分裂。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index 2a37960848..10370530e9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 12069d129227cce13eb5f9f39e636921d4bf9efa -2026-08-09-client-conversation-node-assembly.zh.md: 957c2b291293761ff2417f60093b7962bb75bbdf +2026-08-09-client-conversation-node-assembly.md: e37f2ab3bdf9f3943cb1bcd193d5ea578a0b2c19 +2026-08-09-client-conversation-node-assembly.zh.md: be23b321b710f2f56e3e7af870ba554adb355cef diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 12069d1292..e37f2ab3bd 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -359,7 +359,7 @@ Conversation tests cover every built-in Chat Definition, Assistant Step data, Tu Slot type/runtime tests pin required parent-provided common inject, the `hookContext` type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory. -Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and Code Mode root + nested subcalls against master layout. +Assembled Web snapshots, GUI tests, and browser scenarios cover the real plugin graph. Browser evidence compares Assistant streaming→settled, Bash running→settled, and PTC mode root + nested subcalls against master layout. History-path tests cover complete replace, non-overlapping prepend, complete-range deduplication, partial-overlap rejection, empty-page `hasMore` convergence, and scalar live append. Scalar and packed representations of the same Assistant history produce equal Chat and Trajectory State, timing boundaries, and final Nodes; one packed run remains one Match through replace, prepend, Location replay, and registry rebuild. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 957c2b2912..be23b321b7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -359,7 +359,7 @@ Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Tu Slot type/runtime tests 固定父注册必须提供声明的 common inject、`hookContext` 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。 -Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 Code Mode root + nested subcalls 与 master 的布局。 +Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏览器证据比较 Assistant streaming→settled、Bash running→settled 以及 PTC mode root + nested subcalls 与 master 的布局。 历史链路验证同时覆盖完整 replace、非重叠 prepend、完整 range 去重、部分重叠拒绝、空页 `hasMore` 收敛和 scalar live append。相同 Assistant 历史的 scalar 与 packed 表示产生相同 Chat/Trajectory State、timing boundary 与最终 Node;一个 packed run 在 replace、prepend、Location replay 与 registry rebuild 中始终只保留一个 Match。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 0234f8b784..ca3effd6c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -12,7 +12,7 @@ The direct entry point still needs the same deployment model state as Web-create ## Decision -The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the Code Mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no browser Connection, HTTP server, Web runtime, or browser client. Code Mode and Session persistence are one-shot Agent capabilities independent of Web presentation. +The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The base supplies the disabled module-HMR default; the headless bundle supplies its persona and tool mode, mounts the PTC mode worker explicitly, and inserts `headless-runner` without overriding that policy. Its tree contains no browser Connection, HTTP server, Web runtime, or browser client. PTC mode and Session persistence are one-shot Agent capabilities independent of Web presentation. `headless-runner` is a direct core entry point. After Loader settlement, it reads `ctx.agentDefaultModel.currentSelection()`, creates a fresh persisted Agent through `ctx.agents.create`, installs that `ModelSelection` in the Agent scope, waits for startup quiescence, anchors the Session sequence, submits one ordinary user message, and waits for quiescence again. It awaits `ctx.sessions.flush`, folds its durable event interval for the last non-empty assistant text and final `turn/end` reason, writes the text plus one newline to stdout, and requests bounded launcher shutdown with exit 0 exactly when the reason is `completed`. [Headless reasoning progress](../feature/2026-08-21-headless-reasoning-progress.md) owns the live stderr projection; a terminal `error` reason writes its durable code and message there, and unexpected driver failures also use stderr and exit 1. @@ -34,7 +34,7 @@ Package tests use the real Session store and Agent registry around a scripted Ag | Build a Host-only one-shot bundle around browser RPC | A local one-shot entry point has no client boundary. | | Use the in-process Connection carrier for product-level protocol coverage | Product execution would depend on an unrelated protocol solely to exercise that protocol. | | Give headless a separate provider/model config | Direct and Web creation would have independent defaults and persistence. | -| Omit Code Mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | +| Omit PTC mode and Session persistence | Both capabilities belong to one-shot Agent execution rather than Web presentation. | | Normalize every tuple containing Web and headless bundles | Bundle lists are an extension surface; only the exact installation-owned tuple is safe to classify. | ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index f864c94d69..4da5c95393 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 Code Mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含浏览器 Connection、HTTP server、Web 运行时或浏览器客户端。Code Mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 +随附的 `headless` profile 包含 `dsh-base` 与 `dsh-headless`。base 提供默认禁用模块 HMR(热模块替换)的策略;headless 组合包提供自身的 persona 与工具模式、显式挂载 PTC mode worker,并在不覆盖该策略的情况下插入 `headless-runner`。其插件树不包含浏览器 Connection、HTTP server、Web 运行时或浏览器客户端。PTC mode 与会话持久化均为独立于 Web 呈现的一次性 Agent 能力。 `headless-runner` 是直接使用核心服务的入口。Loader 完全加载后,它读取 `ctx.agentDefaultModel.currentSelection()`,通过 `ctx.agents.create` 创建一个新的持久化 Agent,在 Agent 作用域中安装该 `ModelSelection`,等待启动工作完全停稳,锚定会话事件序号,提交一条普通用户消息,再次等待完全停稳。随后,它等待 `ctx.sessions.flush`,折叠自身持有的持久事件区间,以取得最后一条非空 assistant 文本和最终 `turn/end` 结束原因,将文本连同一个换行写入 stdout,并且仅在结束原因为 `completed` 时请求启动器以退出状态 0 有界关闭。[Headless 推理进度](../feature/2026-08-21-headless-reasoning-progress.zh.md)负责实时 stderr 投影;结束原因为 `error` 时,其持久化错误码与消息写入 stderr,驱动器的意外失败也写入 stderr 并以 1 退出。 @@ -34,7 +34,7 @@ Status: implemented | 围绕浏览器 RPC 构建纯 Host 一次性组合包 | 本地一次性入口没有客户端边界。 | | 使用进程内 Connection carrier 实现产品级协议覆盖 | 产品执行会仅为测试无关协议而依赖该协议。 | | 为 headless 单独提供提供方/模型配置 | 直接创建与 Web 创建会拥有彼此独立的默认值和持久化。 | -| 省略 Code Mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | +| 省略 PTC mode 与会话持久化 | 两项能力都属于一次性 Agent 执行,而不是 Web 呈现。 | | 规范化所有包含 Web 与 headless 组合包的元组 | 组合包列表是扩展面;只有精确的安装过程所属元组可以安全分类。 | ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml similarity index 57% rename from .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml index 6aae8b549f..4ffac0a8b3 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md -2026-08-07-code-mode-executor-collapse.md: 76265d5dd5f37f03c8e56366bf2791ddd2f7cb18 -2026-08-07-code-mode-executor-collapse.zh.md: f2d33993eb815d3f6dcd952557527c9aab5faefb +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md +2026-08-25-rename-code-mode-to-ptc.md: 5637a17b8fcda3eee831e25cbad5662d1a93320b +2026-08-25-rename-code-mode-to-ptc.zh.md: 39cec2dfba133c388f80095f1f40934fb4f0325c diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md new file mode 100644 index 0000000000..5637a17b8f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md @@ -0,0 +1,37 @@ +# Agent Note: Rename code-mode to ptc — the transport is named PTC, user-facing copy says PTC mode + +Status: implemented + +English | [中文](2026-08-25-rename-code-mode-to-ptc.zh.md) + +## Problem + +The tool-registry presentation mode that exposes tools through a generated SDK and the `run_code` transport shipped under the name Code Mode, while the client preset that selects it already shipped as "PTC mode" (locale `presetPtcName: 'PTC mode'`, zh `PTC 模式`). One feature had two names: config values, plugin and event names, files, and documentation said `code`/`code-mode`, and the user-facing name said "PTC mode". A pre-release rename must update every reference together — no compatibility aliases. + +## Decision + +The feature is renamed to PTC (programmatic tool calls). Code identifiers use `ptc` — the transport is not a sibling of plan-mode, so the identifier does not carry `-mode`. User-facing prose keeps "PTC mode" (EN) / "PTC 模式" (zh), matching the shipped preset name. + +Renamed in this PR: + +- config value `tools.mode: 'code'` → `'ptc'` (`ToolPresentationMode` and the zod unions in `dsh-tools` and `dsh-agent-tool-presentation`) +- preset directory `presets/code/` → `presets/ptc/` (preset id `ptc`) +- source and test files `code-mode.ts` → `ptc.ts` and friends; root demo `demo:code-mode` → `demo:ptc` (`scripts/demo-ptc.mjs`) +- the dispatch waterfall `tools/code-dispatch-log` → `tools/ptc-dispatch-log` and types `CodeDispatch*` → `PtcDispatch*` +- prompt rule `tools:code-only` → `tools:ptc-only` +- prose "Code Mode" → "PTC mode" / "PTC 模式" in docs, READMEs, and the eight implemented Agent Notes whose topic names the feature (those files were renamed in place) + +Deferred to the stacked persistence PR: the session-persistent vocabulary — the durable event types `tool/code-dispatch` / `tool/code-dispatch-start`, the logged plugin name `tools-code-mode`, and the sub-call id segment `:code:`. That PR is blocked until the `SESSION_FORMAT_VERSION` v0→v1 migration lands with it. + +Kept unchanged: `run_code` and its `code` parameter (they name the program payload, not the mode), `CodeSdkLanguage`, `CodeRunFailedError`, the `dsh-code-runtime*` package family, the third-party `codex-code-mode-host` binary name, and every frozen archived note. + +## Alternatives considered + +- **`ptc-mode` identifiers** — rejected: PTC is a tool-presentation transport, not a mode in the plan-mode sense, and the identifier should not claim that kinship. +- **Surface-only rename** — rejected: the pre-release stance updates every reference together. +- **Renaming `run_code` too** — rejected: the tool name describes running a program, not the mode, and is model-facing API surface. +- **Renaming the durable event vocabulary in this PR** — rejected: renaming `tool/code-dispatch*` without a format bump would make pre-rename session logs unreadable; that rename belongs to the stacked persistence PR that lands together with the v0→v1 migration. + +## Consequences + +Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are the [session-log-version note](2026-08-10-session-log-version-mechanism.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md). diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md new file mode 100644 index 0000000000..39cec2dfba --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 将 code-mode 重命名为 ptc——传输层命名为 PTC,用户文案使用 PTC mode + +Status: implemented + +[English](2026-08-25-rename-code-mode-to-ptc.md) | 中文 + +## 问题 + +通过生成的 SDK 与 `run_code` 传输层向模型呈现工具的注册表模式,发布时用的名字是 Code Mode;而选择该模式的客户端预设早已以「PTC mode」发布(locale `presetPtcName: 'PTC mode'`,中文 `PTC 模式`)。同一功能有两套名字:配置值、插件与事件名、文件与文档写的是 `code`/`code-mode`,用户可见的名字却是「PTC mode」。预发布阶段的更名必须一次性更新所有引用——不加任何兼容别名。 + +## 决策 + +该功能更名为 PTC(programmatic tool calls,程序化工具调用)。代码标识符使用 `ptc`——该传输层并非 plan-mode 的同类模式,因此标识符不带 `-mode`。用户可见文案沿用「PTC mode」(英文)/「PTC 模式」(中文),与已发布的预设名一致。 + +本 PR 完成的重命名包括: + +- 配置值 `tools.mode: 'code'` → `'ptc'`(`ToolPresentationMode` 以及 `dsh-tools`、`dsh-agent-tool-presentation` 中的 zod union) +- 预设目录 `presets/code/` → `presets/ptc/`(预设 id 为 `ptc`) +- 源文件与测试文件 `code-mode.ts` → `ptc.ts` 等;根 demo `demo:code-mode` → `demo:ptc`(`scripts/demo-ptc.mjs`) +- 分发 waterfall `tools/code-dispatch-log` → `tools/ptc-dispatch-log`,类型 `CodeDispatch*` → `PtcDispatch*` +- 提示词规则 `tools:code-only` → `tools:ptc-only` +- 文档、README 与八个以该功能命名的 implemented Agent Note 中的文案 "Code Mode" → "PTC mode"/"PTC 模式"(这些 Note 文件一并就地改名) + +延后到堆叠的持久化 PR:会话持久词汇——持久事件类型 `tool/code-dispatch`/`tool/code-dispatch-start`、日志中的插件名 `tools-code-mode`、子调用 id 段 `:code:`。该 PR 被阻塞,直到 `SESSION_FORMAT_VERSION` v0→v1 迁移与其一同落地。 + +保持不变:`run_code` 及其 `code` 参数(它们描述程序载荷,而非模式)、`CodeSdkLanguage`、`CodeRunFailedError`、`dsh-code-runtime*` 包族、第三方二进制名 `codex-code-mode-host`,以及所有冻结的 archived Note。 + +## 备选方案 + +- **使用 `ptc-mode` 标识符**——否决:PTC 是工具呈现传输层,不是 plan-mode 意义上的模式,标识符不应宣示这种亲缘关系。 +- **仅重命名表面**——否决:预发布立场要求一次性更新所有引用。 +- **连 `run_code` 一起改名**——否决:该工具名描述的是运行程序,不是模式,而且是对模型可见的 API 表面。 +- **在本 PR 中一并重命名持久事件词汇**——否决:在没有格式版本提升的情况下重命名 `tool/code-dispatch*` 会让更名前的会话日志无法读取;该重命名属于与 v0→v1 迁移一同落地的堆叠持久化 PR。 + +## 后果 + +配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [session log 版本机制 Note](2026-08-10-session-log-version-mechanism.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml index c7d62f839f..478178ef58 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.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-25-sparse-first-party-prompt-section-orders.md -2026-08-25-sparse-first-party-prompt-section-orders.md: 2bf2e7441b449a6cfbd5b845f1d7e97b3fab09ae -2026-08-25-sparse-first-party-prompt-section-orders.zh.md: 624ed51f4d40848c72091097900ef05ec35fdd2e +2026-08-25-sparse-first-party-prompt-section-orders.md: 4b2568f18d104a77d00f91bb98f64047ecd85fa9 +2026-08-25-sparse-first-party-prompt-section-orders.zh.md: 4dfb0d0bd87ff5f245c28f3dbab6a48ba898f924 diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md index 2bf2e7441b..4b2568f18d 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.md @@ -22,7 +22,7 @@ The allocation preserves the established first-party sequence except for two del |---|---| | Product opening | `harness:identity` −1000, `harness:source` −900, `app:web-surface` −800, `deployment:persona` 0 | | Work modes | `plan:policy` 500, `team:policy` 600 | -| Invocation prelude | `tools:code-only` 800, `context:file-reference` 900 | +| Invocation prelude | `tools:ptc-only` 800, `context:file-reference` 900 | | Local tools | `tool:bash` 1000, `tool:pwsh` 1010, `tool:read` 1100, `tool:write` 1200, `tool:edit` 1300, `tool:glob` 1400, `tool:grep` 1500, `tool:jobs` 1600, `tool:pty` 1700 | | Higher-level tools | `tool:web_search` 2000, `tool:web_fetch` 2100, `tool:lsp` 2200, `tool:session-query` 2300, `tool:goal` 2400, `tool:cordis` 2500, `tool:workflow` 2600, `tool:ralph` 2700, continuable-subagent guidance 2800, `tool:report` 2900 | | Generated protocol | `tools:sdk` 5000 | diff --git a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md index 624ed51f4d..4dfb0d0bd8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-sparse-first-party-prompt-section-orders.zh.md @@ -22,7 +22,7 @@ Status: implemented |---|---| | 产品开场 | `harness:identity` −1000、`harness:source` −900、`app:web-surface` −800、`deployment:persona` 0 | | 工作模式 | `plan:policy` 500、`team:policy` 600 | -| 调用前置说明 | `tools:code-only` 800、`context:file-reference` 900 | +| 调用前置说明 | `tools:ptc-only` 800、`context:file-reference` 900 | | 本地工具 | `tool:bash` 1000、`tool:pwsh` 1010、`tool:read` 1100、`tool:write` 1200、`tool:edit` 1300、`tool:glob` 1400、`tool:grep` 1500、`tool:jobs` 1600、`tool:pty` 1700 | | 高层工具 | `tool:web_search` 2000、`tool:web_fetch` 2100、`tool:lsp` 2200、`tool:session-query` 2300、`tool:goal` 2400、`tool:cordis` 2500、`tool:workflow` 2600、`tool:ralph` 2700、可继续运行的 subagent 指导 2800、`tool:report` 2900 | | 生成协议 | `tools:sdk` 5000 | diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml index 4d6d2f9225..5bd489c829 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md -2026-07-26-code-dispatch-log-spill.md: a4b8deee86b1f6102e48e34079a93a74dfcfa288 -2026-07-26-code-dispatch-log-spill.zh.md: 5e53d761367ddce7747109e49c7facea9bcdae49 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md +2026-08-07-ptc-executor-collapse.md: 272748283a872662c3ee02276240303a5c7d54fa +2026-08-07-ptc-executor-collapse.zh.md: c65feb3aef6a471801cae1cdf3c49b7330b0951b 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-ptc-executor-collapse.md similarity index 66% rename from .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md rename to .agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md index 76265d5dd5..906e3fc734 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md @@ -1,12 +1,12 @@ -# Agent Note: Code Mode collapses the executor, not just the wire +# Agent Note: PTC mode collapses the executor, not just the wire Status: implemented -English | [中文](2026-08-07-code-mode-executor-collapse.zh.md) +English | [中文](2026-08-07-ptc-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. +`mode: 'ptc'` 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. @@ -16,7 +16,7 @@ The package contract names this exact anti-pattern: schema omission is not enfor 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. +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 [PTC mode foundation](../feature/2026-06-15-ptc.md) owns the transport design this note layers the execution boundary onto. ## Alternatives considered @@ -26,7 +26,7 @@ The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder ### 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. +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-enPTC mode semantics the registry already owns. ### Reject via a shipped guard @@ -38,9 +38,9 @@ No provider guarantees interception of unadvertised names; the reported session ## 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). +- `mode: 'ptc'` 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-observation-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 first-party per-tool guidance. 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. The TypeScript SDK section repeats the distinction next to the generated declarations, labels them as program-only bindings, and states that only separately supplied tool schemas grant direct-call availability. Because the declaration list can otherwise be read as native tool availability, the section emits a complete `run_code` call around `tools.bash(...)` when the current `bash` parameter schema accepts the example arguments. `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. +- The prompt STATES the collapse, in the `tools:ptc-only` section ordered ahead of first-party per-tool guidance. 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. The TypeScript SDK section repeats the distinction next to the generated declarations, labels them as program-only bindings, and states that only separately supplied tool schemas grant direct-call availability. Because the declaration list can otherwise be read as native tool availability, the section emits a complete `run_code` call around `tools.bash(...)` when the current `bash` parameter schema accepts the example arguments. `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 `ptc-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-ptc-executor-collapse.zh.md similarity index 69% rename from .agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md rename to .agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md index f2d33993eb..13dfe7b303 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md @@ -1,12 +1,12 @@ -# Agent Note: Code Mode 塌缩执行器而非仅通告面 +# Agent Note: PTC mode 塌缩执行器而非仅通告面 Status: implemented -[English](2026-08-07-code-mode-executor-collapse.md) | 中文 +[English](2026-08-07-ptc-executor-collapse.md) | 中文 ## 问题 -`mode: 'code'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 +`mode: 'ptc'` 只塌缩了通告面,没有塌缩执行面。`wireSchemas()` 只向模型发送一个工具——`run_code`——但执行器通过 `get()` 解析所有调用,而 `get()` 返回完整的可见工具表外加保留的传输工具。模型一旦发出原生工具名(`write`、`read`、`bash`、`subagent` 等),就能完全绕过 `run_code`:调用照常走完整流水线并执行成功,尽管它的 schema 从未被通告过。模型提供方不拦截未通告的工具名,因此不发 schema 等于没有约束。 包契约点名了这个反模式:当直接调用方可以绕过时,schema 省略不算强制执行;拒绝必须经执行器验证。 @@ -16,7 +16,7 @@ Status: implemented 执行链路的四处查表——`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.zh.md) 之上,传输设计由后者拥有。 +塌缩是安全相关的不变量,因此验收经执行器钉死:`code` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [PTC mode 基础](../feature/2026-06-15-ptc.zh.md) 之上,传输设计由后者拥有。 ## 备选方案 @@ -38,9 +38,9 @@ guard 是可选的插件扩展;安全不变量不能依赖部署恰好组装 ## 后果 -- `mode: 'code'` 现在兑现其通告:模型直呼原生工具变为 `UNKNOWN_TOOL`,模型可以通过改走 `run_code` 自行纠正(已中止的调用仍按取消契约解析为 `ABORTED_BEFORE_DISPATCH`)。 +- `mode: 'ptc'` 现在兑现其通告:模型直呼原生工具变为 `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-observation-policy 要求先 `read` 再 `write`、一两个委派用 `subagent` 而非 `workflow`)。防止模型直呼原生工具的是执行器塌缩,而非提示词过滤。 -- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:code-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。TypeScript SDK 段在生成声明旁再次区分两者,将其标为只能在程序内使用的绑定,并说明只有单独提供的工具 schema 才赋予直呼权限。声明列表可能被误读为原生工具可用性,因此当当前 `bash` 参数 schema 接受示例参数时,该段会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `code-mode-turn` 共用期望提示词的原因。 +- 提示词会**声明**这条塌缩,位于 first-party 逐工具指导之前的 `tools:ptc-only` 段。那些段只写出工具名而不限定其可达方式,因此只读到它们的模型会发出原生调用,为一个同一份提示词刚刚声明过的工具收到 `UNKNOWN_TOOL`,进而判定部署不一致,而不是自行纠正。拒绝信息给出正确路径也是同一原因。TypeScript SDK 段在生成声明旁再次区分两者,将其标为只能在程序内使用的绑定,并说明只有单独提供的工具 schema 才赋予直呼权限。声明列表可能被误读为原生工具可用性,因此当当前 `bash` 参数 schema 接受示例参数时,该段会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`both` 下该规则渲染为空:它的原生调用确实会执行,在那里声明就是假话——这也是 `both-mode-turn` 不再与 `ptc-turn` 共用期望提示词的原因。 - 未来任何设置 `parent` token 的组合传输,其子调用自动走全表,与该 token 已有的嵌套调用语义一致。 diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml similarity index 63% rename from .agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml rename to .agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 13987435b3..03bad269c4 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-code-mode.md -2026-06-15-code-mode.md: dbf77557c9f1aa59deed443b74ca3ef83137d773 -2026-06-15-code-mode.zh.md: 091623adcf1b543b104a20482f7a0106a6437f1d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-15-ptc.md +2026-06-15-ptc.md: 144ef987cf5a7c589237401a1adfec3cb6825504 +2026-06-15-ptc.zh.md: 24a6f813ec3eaa551c7e2ac8fcdd9762104de15b diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md similarity index 75% rename from .agents/notes/implemented/feature/2026-06-15-code-mode.md rename to .agents/notes/implemented/feature/2026-06-15-ptc.md index dbf77557c9..144ef987cf 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -1,8 +1,8 @@ -# Agent Note: Code Mode — the model writes TypeScript against the tool registry +# Agent Note: PTC mode — the model writes TypeScript against the tool registry Status: implemented -English | [中文](2026-06-15-code-mode.zh.md) +English | [中文](2026-06-15-ptc.zh.md) ## Problem @@ -10,7 +10,7 @@ In the registry's native presentation, the agent loop advertises every visible c For multi-step tool work this is token-heavy and serial. The model cannot compose tools — loop over a result set, branch on an intermediate value, fan out, post-process — without a full model round-trip per call, and each round-trip drags the entire intermediate result back into context whether the model needs it or not. -Cloudflare's [Code Mode](https://blog.cloudflare.com/code-mode/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. +Cloudflare's [PTC mode](https://blog.cloudflare.com/ptc/) proposes an alternative grounded in a simple observation: LLMs are better at writing code than at emitting tool calls, because they have seen millions of lines of real code and comparatively few contrived tool-calling traces. Instead of one tool call per step, the model writes a TypeScript program against a generated API over the tools, the program executes in a sandboxed runtime, and the model curates what comes back — only what it prints or returns — instead of every intermediate result. Tool presentation belongs to the registry that owns tool visibility: implementing a second presentation as an after-the-fact waterfall transform would make correctness depend on listener order and fight [reconstructable requests](../architecture/2026-07-05-reconstructable-requests.md). The execution substrate is also part of the foundation rather than a placeholder: Node `worker_threads` provides a separate isolate, an empty environment, heap caps, and termination of a hot synchronous loop, while fitting the harness's existing trust model (§Trust posture). @@ -18,43 +18,43 @@ Tool presentation belongs to the registry that owns tool visibility: implementin Three decisions, each elaborated in its own section below: -1. **Code Mode is a first-class presentation mode of `ToolRuntime`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. +1. **PTC mode is a first-class presentation mode of `ToolRuntime`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the Service Definition package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); Consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another Service Provider package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker-thread`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. -This note owns Code Mode's presentation, composition, isolation, and settlement foundation. The later [typed tool-return Agent Note](2026-07-20-code-mode-typed-tool-returns.md) owns the generated output map, canonical binding values, `ToolCallError`, and the lossless outer-output boundary. +This note owns PTC mode's presentation, composition, isolation, and settlement foundation. The later [typed tool-return Agent Note](2026-07-20-ptc-typed-tool-returns.md) owns the generated output map, canonical binding values, `ToolCallError`, and the lossless outer-output boundary. ### The registry owns the mode -`ToolRuntime` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: code }`) — no code edit, per the no-hardcoded-tunables convention. +`ToolRuntime` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: ptc }`) — no code edit, per the no-hardcoded-tunables convention. **Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'code'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. -**Interaction with `toolOrder`:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'code'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using Code Mode updates its order config or drops it. +**Interaction with `toolOrder`:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'ptc'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using PTC mode updates its order config or drops it. -**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. +**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. -**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable Code Mode protocol when the deployment expects Code Mode to remain usable; no restoration pass overrides deliberate composition. +**Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. **Codegen.** `jsonSchemaToTs()` maps the `defineTool` JSON-Schema subset to TypeScript, carries schema descriptions into JSDoc, and degrades unsupported constructs to `unknown`. The SDK exposes tools as quoted object keys, supporting arbitrary names without aliases or collisions. Typing is advisory because the runtime strips types before execution. ### The run_code tool and the dispatch bridge -Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove Code Mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: +Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove PTC mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/code-dispatch-start`/`tool/code-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/ptc-dispatch-start`/`tool/ptc-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. **Sub-call contexts are deferred through the parent.** Injecting inside `run_code` would break parent call/result adjacency, so `ToolRunContext.deferContext()` collects every sub-result `additionalContexts` entry in dispatch order. The registry carries that array even when the program later throws, and the loop appends each entry only after the outer result and every sibling result in the step. An outer post-execute block discards tool-deferred entries and exposes only contexts explicitly attached by the blocking decision. -**Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler that replaced it. +**Concurrency is bounded, not serialized.** Each run owns a dispatch queue that starts calls strictly in submission order and classifies each one through `registry.executionMode`, the same fail-closed `isConcurrencySafe` contract the native loop uses. Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (default 10; `1` restores serial dispatch); an exclusive call drains the pool and runs alone. Settlement abandons queued calls that have not started. This note shipped the serialized placeholder; the [live-parallel Agent Note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduler that replaced it. **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). -### Observability: `tool/code-dispatch` +### Observability: `tool/ptc-dispatch` -Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool entry and a `tool/code-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. +Each sub-dispatch appends a log-only `tool/ptc-dispatch-start` event at pool entry and a `tool/ptc-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. ### The code-runtime seam @@ -64,9 +64,9 @@ Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool en - `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }` — the runtime exposes each namespace as a global object of async functions inside the program; the optional descriptor asks the runtime to inject a real program-visible rejection class without teaching the seam consumer-specific names. `CodeJsonValue` is this dependency-light seam's structural lossless-JSON type, so binding arguments and resolutions cross the implementation's serialization boundary whole. - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }` — program execution outcomes resolve as the `error` field. `run()` may reject only for caller/seam misuse (for example a duplicate binding namespace); consumers still contain a non-conforming backend rejection at their own error boundary. - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }` — orthogonal outcomes reported independently per [defensive patterns](../../../../docs/defensive-patterns.md); a timed-out run is not an exception, an abort is not a timeout, a lossy completion is not an overflow, and a substrate exit is none of them. -- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the first backend; a Python backend says `'python'` and pairs with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` accepts any `language` with a registered SDK renderer and `run_code` flavor (TypeScript and Python ship; see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)) and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). +- Two readonly backend descriptors, informational not gating: `language` (what the program must be written in — `'typescript'` for the first backend; a Python backend says `'python'` and pairs with its own SDK generator on the presentation side) and `isolation` (`'worker-thread'` for the shipped backend; `'process'`, `'container'`, … for future ones). `dsh-tools` accepts any `language` with a registered SDK renderer and `run_code` flavor (TypeScript and Python ship; see the [language-dispatch note](2026-07-31-ptc-language-dispatch.md)) and fails the assembly loudly otherwise, the same misconfiguration idiom as `toolOrder` violations (as when `mode` is non-native with no `ctx.codeRuntime` loaded at all). -Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when Code Mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. +Requests contain every runtime input; implementations own validated timeout and cap defaults. The registry looks up the optional runtime only when PTC mode is assembled, so native mode does not depend on one. Missing or language-incompatible runtimes fail loudly. Alternate substrates or languages can replace the implementation behind the same seam, paired with the appropriate SDK generator. ### The worker-thread runtime @@ -74,18 +74,18 @@ Requests contain every runtime input; implementations own validated timeout and 1. **Type-strip host-side** with Node's built-in `stripTypeScriptTypes` (`node:module`; present across the repo's whole engines range, `^22.19.0 || >=24.0.0`, and position-preserving, so runtime error line numbers match the model's source). Strip-only mode rejects non-erasable syntax (`enum`, namespaces) — that rejection returns as `error.kind: 'exception'` with Node's message, the SDK instructions say "erasable TypeScript only", and the model self-corrects like any other program error. A syntax-level failure never spawns a worker. 2. **Spawn one fresh `Worker` per run** from the package's own bootstrap module: `env: {}` (truly empty — stronger than the scrubbed-env rule for spawned commands), `resourceLimits` from config, `stdout`/`stderr` captured into `logs` rather than inherited. No pooling and no cross-run state: a program's world dies with its worker, which keeps runs reconstructable from the log alone and makes state bleed unrepresentable. -3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. Code Mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute. +3. **Execute** in the bootstrap: the stripped program becomes the body of an `AsyncFunction` whose parameters are the binding globals, any consumer-declared rejection classes, and a capturing `console` shim, so top-level `await` and `return` work. PTC mode declares `ToolCallError` with member property `toolName`; the runtime materializes that real constructor without hardcoding tools. A lossless JSON completion crosses exactly; `undefined` remains absence, a lossy value is `invalid-output`, and an oversized outer result is `output-limit` rather than an inspected-string substitute. 4. **Bridge bindings over the message port**: each binding function in the worker posts `{ id, global, name, args }` and awaits the reply; the host validates the name against the request's bindings, invokes, and replies `{ id, ok, value }` or `{ id, ok: false, message }` (a host-side binding rejection becomes a program-side rejection). The worker-side namespace objects are built null-prototype via `defineProperty`, so a binding named `__proto__`, `constructor`, or `toString` is an ordinary own property, not a prototype collision. Unknown names, duplicate ids, and post-settlement messages are rejected or ignored — the port protocol assumes a hostile peer, because the peer runs model code. 5. **Enforce independent budgets.** `computeMs` meters worker busy time, allowing slow awaited tools without excusing a hot loop. `maxWallMs` bounds total elapsed time, including unresolved waits. `maxOutputBytes` bounds only the combined serialized outer logs, completion, or diagnostic; intermediate binding values have no byte cap. Expiry, cancellation, and completion terminate the worker, and heap exits or outer overflow are explicit failures. 6. **Dispose to quiescence**: the service's own disposal terminates in-flight workers and *awaits* their exits before resolving, per [defensive patterns](../../../../docs/defensive-patterns.md). ### Trust posture -The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. `worker.terminate()` stops the thread but not OS processes it spawned. Code Mode uses the same `tools/pre-execute` policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend. +The worker runtime provides containment, not a security boundary: model code can reach Node APIs and has authority comparable to the bash tool. `worker.terminate()` stops the thread but not OS processes it spawned. PTC mode uses the same `tools/pre-execute` policy gate as bash and adds an empty environment, heap limits, a separate isolate, and hard termination of the program itself. Deployments that need a hard multi-tenant boundary need a container-class backend for both code and bash; the runtime's isolation descriptor lets them distinguish that backend. ### What the model sees -The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-code-mode-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Both flavors state the same contract in their own primitive: independent read-only calls MAY overlap under `Promise.all` (TypeScript) or `asyncio.gather` (Python), mutating calls run alone in submission order, and dependent work sequences with `await`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. +The SDK instructs the model to write an async body in the loaded runtime's language (an erasable-TypeScript body by default; a Python `async` body under a Python runtime — see the [language-dispatch note](2026-07-31-ptc-language-dispatch.md)), call tools through `await tools.name(args)`, catch rejected tool calls when needed, and return or log only the output that should re-enter context. Both flavors state the same contract in their own primitive: independent read-only calls MAY overlap under `Promise.all` (TypeScript) or `asyncio.gather` (Python), mutating calls run alone in submission order, and dependent work sequences with `await`. The declaration prefix can be as large as native schemas, especially in `'both'`, but remains stable for provider caching. The transport's own `description` and both SDK instruction flavors open by naming `code` and `description` as the call's two required arguments. Prose that describes the call as passing a program leaves the second argument discoverable only through the parameter schema, and a model that emits `{code}` alone loses the whole written program to an `INVALID_ARGS` rejection. @@ -97,8 +97,8 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem - **Worker runtime:** Real-worker tests cover typed binding values and failures, every lossless JSON completion root, invalid and over-limit output, exact combined ledger boundaries, compute and wall budgets, hostile binding traffic, empty environment, and disposal to quiescence. A built-package test runs the worker entry under plain Node. - **Registry integration:** Tests cover code generation, all presentation modes, reserved-name and restriction rules, scoped visibility, authoritative assembly rewrites, `toolOrder`, runtime compatibility failures, full-pipeline sub-dispatch, parent-token correlation, serialization, cancellation and queue drain, JSON normalization, error propagation, log events, ordered context deferral across successful and failed programs, outer-block suppression, and HMR cleanup. -- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a Code Mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. -- **Snapshot:** The `code-mode-turn`, `both-mode-turn`, and `code-mode-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. +- **With-key e2e:** A real model composes two bash calls in one program; another discovers nested workspace instructions through a PTC mode fs dispatch. The tests verify collapsed request headers, correlated dispatch events, resulting files, deferred context, and model behavior. +- **Snapshot:** The `ptc-turn`, `both-mode-turn`, and `ptc-workspace-context` fixtures pin SDK text, header tool lists, dispatch events, deferred context, and result cards. ## Alternatives considered @@ -106,9 +106,9 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **`node:vm` as the reference runtime, with hardening deferred.** Rejected: `node:vm` is not isolation (prototype-chain escapes reach the host realm) and cannot interrupt a hot loop. A worker thread provides a separate isolate, empty environment, `resourceLimits`, and reliable `terminate()` at bash-equivalent trust, so the reference and production implementation are one package without an unsafe-acknowledgement ceremony. -**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under Code Mode for residual native calls. +**Result elision / summarization over native tool-calling.** Addresses only the context-bloat half of the problem: trimming old `tool-result`s is cheap to add as a logged surface replacement under reconstructable requests, but still pays one model round-trip per call and cannot express loops, branches, or joins. Complementary, not competing; it can layer under PTC mode for residual native calls. -**Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. Code Mode's queue decision kept the two compatible, and both later shipped: the metadata as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch plus per-tool binding parallelism on the same classifier. +**Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. PTC mode's queue decision kept the two compatible, and both later shipped: the metadata as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch plus per-tool binding parallelism on the same classifier. **Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it. @@ -126,10 +126,10 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **Prompt cost of the SDK, especially under `'both'`.** The `.d.ts` can rival the native schemas it complements; `'both'` carries two representations. Prefix stability + provider caching amortize per-session cost; the mode is per-deployment; the Agent Note makes no unconditional-savings claim. Measured guidance (when to prefer which mode) is explicitly post-ship learning. -**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Package modules separate these responsibilities (`ts-types.ts` and `code-mode.ts` beside `schema.ts`, `json-schema.ts`, and `presentation.ts`), while `ctx.codeRuntime` owns all code-runtime-specific implementation. +**Registry scope growth.** `dsh-tools` absorbs codegen, a tool, a bridge, and an event. Package modules separate these responsibilities (`ts-types.ts` and `ptc.ts` beside `schema.ts`, `json-schema.ts`, and `presentation.ts`), while `ctx.codeRuntime` owns all code-runtime-specific implementation. **Large lossless JSON values can exhaust memory.** Tool bindings snapshot lossless JSON before dispatch and return canonical JSON resolutions whole. The runtime validates both sides of the worker port and applies no per-binding byte cap; structured-clone cost and process or worker memory are the practical bounds. The combined outer-output ledger for logs, the completion value, and a failure diagnostic is the only byte-capped boundary. -**Sub-dispatch overlap is bounded by tool safety claims, not by the caller.** A program's `Promise.all` or `asyncio.gather` buys wall-clock parallelism only across calls the tool itself classifies concurrency-safe; a run of exclusive calls still costs its round-trips in sequence, and models may over-expect. Both flavors' SDK instructions state the real contract. This note shipped the serialized placeholder that made the risk absolute; the [live-parallel Agent Note](2026-07-26-code-mode-live-parallel-dispatch.md) owns the scheduler and its overlap cap. +**Sub-dispatch overlap is bounded by tool safety claims, not by the caller.** A program's `Promise.all` or `asyncio.gather` buys wall-clock parallelism only across calls the tool itself classifies concurrency-safe; a run of exclusive calls still costs its round-trips in sequence, and models may over-expect. Both flavors' SDK instructions state the real contract. This note shipped the serialized placeholder that made the risk absolute; the [live-parallel Agent Note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduler and its overlap cap. **Budget metering reads the event loop, not a flag.** Busy-time polling (`eventLoopUtilization()`) is coarser than an exact CPU meter — a budget expires up to one poll interval late — and its correctness claim ("a pending dispatch cannot pause it") is load-bearing against a hostile program. Both sides are unit-tested (hot loop with a pending decoy dispatch dies at `computeMs`; idle-on-slow-binding survives to `maxWallMs`), and the poll interval is an internal constant, not config — nothing a deployment could mis-tune into a bypass. `maxWallMs` is config, and it reaches `setTimeout`, which clamps a delay above `MAX_TIMER_DELAY_MS` (2^31-1 ms) to 1 ms; a positivity check alone therefore accepts a 25-day ceiling that expires on the first tick and times out every run. The worker runtime range-checks the field at load for that reason. `computeMs` needs no upper bound because it is compared against measured utilization instead of being handed to a timer. diff --git a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md similarity index 75% rename from .agents/notes/implemented/feature/2026-06-15-code-mode.zh.md rename to .agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 091623adcf..24a6f813ec 100644 --- a/.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -1,8 +1,8 @@ -# Agent Note: Code Mode——模型针对工具注册表编写 TypeScript +# Agent Note: PTC mode——模型针对工具注册表编写 TypeScript Status: implemented -[English](2026-06-15-code-mode.md) | 中文 +[English](2026-06-15-ptc.md) | 中文 ## 问题 @@ -10,7 +10,7 @@ Status: implemented 对于多步工具操作,这种方式 token 开销大且串行。模型无法组合工具——遍历结果集、根据中间值分支、扇出、后处理——每次调用都需要一次完整的模型往返,而每次往返都会把完整的中间结果拖回上下文,不管模型是否需要。 -Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 +Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替代方案,基于一个简单的观察:LLM(大语言模型)编写代码的能力优于发出工具调用,因为它们见过数百万行真实代码,而人为构造的工具调用 trace 相对很少。模型不再每步发出一次工具调用,而是针对工具生成的 API 编写一段 TypeScript 程序,程序在沙箱运行时中执行,模型只筛选返回的内容——仅限它 print 或 return 的部分——而非所有中间结果。 工具呈现属于掌管工具可见性的注册表:如果把第二种呈现方式实现为事后的 waterfall(瀑布式事件)变换,正确性将依赖监听器顺序,并与[可重建请求](../architecture/2026-07-05-reconstructable-requests.zh.md)冲突。执行基底同样属于基础设施而非占位实现:Node `worker_threads` 提供独立 isolate、空环境、堆上限以及对热同步循环的终止能力,同时契合 harness 既有的信任模型(§信任姿态)。 @@ -18,43 +18,43 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 三项决策,各自在下方独立小节中展开: -1. **Code Mode 是 `ToolRuntime`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 +1. **PTC mode 是 `ToolRuntime`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含 Service Definition 包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.zh.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个 Service Provider 包,而非重新设计。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker-thread`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 -本说明负责定义 Code Mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-code-mode-typed-tool-returns.zh.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。 +本说明负责定义 PTC mode 的呈现、组合、隔离与结算基础。后续的[类型化工具返回值 Agent Note](2026-07-20-ptc-typed-tool-returns.zh.md)负责定义生成的输出映射、规范绑定值、`ToolCallError` 和无损外层输出边界。 ### 注册表拥有模式 -`ToolRuntime` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: code }`),无需改代码,遵循 no-hardcoded-tunables 约定。 +`ToolRuntime` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: ptc }`),无需改代码,遵循 no-hardcoded-tunables 约定。 **协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。 -**与 `toolOrder` 的交互:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'code'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 Code Mode 的部署需要更新其 order 配置或移除它。 +**与 `toolOrder` 的交互:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'ptc'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 PTC mode 的部署需要更新其 order 配置或移除它。 -**SDK 提示词段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-code-mode-language-dispatch.zh.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 +**SDK 提示词段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 -**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 Code Mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 +**组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 **代码生成。** `jsonSchemaToTs()` 将 `defineTool` 的 JSON Schema 子集映射为 TypeScript,将 schema 描述带入 JSDoc,不支持的构造降级为 `unknown`。SDK 将工具暴露为带引号的对象键,支持任意名称而无需别名或冲突处理。类型是建议性的,因为运行时在执行前会剥离类型。 ### run_code 工具与分发桥 -在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 Code Mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: +在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 PTC mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/ptc-dispatch-start`/`tool/ptc-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 **子调用上下文通过父调用延后。** 在 `run_code` 内部注入会破坏父调用/结果的相邻性,因此 `ToolRunContext.deferContext()` 按分发顺序收集每个子结果的 `additionalContexts` 条目。即使程序后来抛出异常,注册表仍携带该数组;循环只在外层结果与步骤中所有兄弟结果之后追加每个条目。外层 post-execute 阻止会丢弃工具延后的条目,只暴露阻止 decision 显式附加的上下文。 -**并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 约定相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 负责。 +**并发是有界的,而非被序列化。** 每次 run 拥有一个分发队列,严格按提交顺序启动调用,并通过 `registry.executionMode` 对每个调用分类——与原生循环所用的 fail-closed `isConcurrencySafe` 约定相同。连续的 parallel 类调用最多重叠 `maxParallelSubCalls` 个(默认 10;设为 `1` 恢复串行分发);exclusive 类调用会排空池并单独运行。结算时放弃尚未开始的排队调用。本 note 交付的是被序列化的占位实现;取代它的调度器由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责。 **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.zh.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 -### 可观测性:`tool/code-dispatch` +### 可观测性:`tool/ptc-dispatch` -每次子分发在进入分发池时追加一个仅日志的 `tool/code-dispatch-start` 事件,并以一个 `tool/code-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 +每次子分发在进入分发池时追加一个仅日志的 `tool/ptc-dispatch-start` 事件,并以一个 `tool/ptc-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 ### code-runtime seam @@ -64,9 +64,9 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 - `CodeBindingNamespace = { global: string; functions: Record Promise>; errorClass?: { name: string; memberNameProperty: string } }`——运行时将每个命名空间作为程序内部的全局异步函数对象暴露;可选描述符要求运行时注入真正的、程序可见的 reject 类,而无需让 seam 获知消费方专用名称。`CodeJsonValue` 是这个低依赖 seam 的结构化无损 JSON 类型,因此绑定参数与返回值可以完整跨越实现的序列化边界。 - `CodeRunResult = { value?: CodeJsonValue; logs: string[]; error?: CodeRunFailure }`——程序执行失败时,执行 promise 仍会 fulfill,并通过 `error` 字段返回失败结果。只有调用方/seam 误用(例如重复的绑定命名空间)时,`run()` 才会 reject;消费方仍在自己的错误边界处理不合规后端的拒绝。 - `CodeRunFailure = { kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' | 'invalid-output' | 'output-limit'; message: string }`——按[防御性模式](../../../../docs/defensive-patterns.zh.md)独立报告的正交结果;超时的 run 不是异常,abort 不是超时,有损完成值不是溢出,基底退出也与上述情况相互独立。 -- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——首个后端为 `'typescript'`;Python 后端声明 `'python'`,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 接受任何注册了 SDK 渲染器与 `run_code` flavor 的 `language`(TypeScript 与 Python 已交付;见[语言分发 note](2026-07-31-code-mode-language-dispatch.zh.md)),否则组装会显式失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 +- 两个只读的后端描述符,仅供信息参考而非门禁判定:`language`(程序必须使用的语言——首个后端为 `'typescript'`;Python 后端声明 `'python'`,并在呈现侧配对自己的 SDK 生成器)和 `isolation`(交付的后端为 `'worker-thread'`;未来可为 `'process'`、`'container'` 等)。`dsh-tools` 接受任何注册了 SDK 渲染器与 `run_code` flavor 的 `language`(TypeScript 与 Python 已交付;见[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md)),否则组装会显式失败,与 `toolOrder` 违规时的配置错误惯用法相同(如 `mode` 为非 native 但根本没有加载 `ctx.codeRuntime`)。 -请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 Code Mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会显式失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 +请求包含所有运行时输入;实现方拥有经校验的超时和上限默认值。注册表仅在组装 PTC mode 时查找可选的运行时,因此 native 模式不依赖它。缺失或语言不兼容的运行时会显式失败。替代基底或语言可以在同一 seam 背后替换实现,配对相应的 SDK 生成器。 ### worker-thread 运行时 @@ -74,18 +74,18 @@ Cloudflare 的 [Code Mode](https://blog.cloudflare.com/code-mode/) 提出了一 1. **宿主侧 type-strip**,使用 Node 内置的 `stripTypeScriptTypes`(`node:module`;在本仓库的整个引擎范围 `^22.19.0 || >=24.0.0` 内可用,且会保留源码位置,因此运行时错误行号与模型源码一致)。仅剥离模式拒绝不可擦除的语法(`enum`、namespaces)——该拒绝以 `error.kind: 'exception'` 加 Node 的消息返回,SDK 说明写明「仅限可擦除 TypeScript」,模型像处理其他程序错误一样自我修正。语法级失败不会 spawn worker。 2. **每次 run spawn 一个全新 `Worker`**,来自包自身的 bootstrap 模块:`env: {}`(真正为空——比 spawn 命令的 scrubbed-env 规则更严格),`resourceLimits` 来自配置,`stdout`/`stderr` 捕获到 `logs` 而非继承。不做池化,不跨 run 保留状态:程序的世界随 worker 消亡,这使得 run 仅从日志即可重建,状态泄漏不可表达。 -3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用。Code Mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。 +3. **在 bootstrap 中执行**:剥离后的程序成为一个 `AsyncFunction` 的函数体,其参数是绑定全局变量、消费方声明的 reject 类和一个捕获式 `console` shim,因此顶层 `await` 和 `return` 可用。PTC mode 声明 `ToolCallError`,成员属性为 `toolName`;运行时无需硬编码工具即可实体化真正的构造函数。无损 JSON 完成值会精确跨越边界;`undefined` 仍表示缺席,有损值产生 `invalid-output`,过大的外层结果产生 `output-limit`,而不会退化为检查格式化后的字符串替代品。 4. **通过消息端口桥接绑定**:worker 中的每个绑定函数发送 `{ id, global, name, args }` 并等待回复;宿主根据请求的绑定校验名称、调用、并回复 `{ id, ok, value }` 或 `{ id, ok: false, message }`(宿主侧绑定拒绝变为程序侧 rejection)。worker 侧的命名空间对象通过 `defineProperty` 构建为 null-prototype,因此名为 `__proto__`、`constructor` 或 `toString` 的绑定是普通自有属性,而非原型链碰撞。未知名称、重复 id 和结算后消息被拒绝或忽略——端口协议假设对端是恶意的,因为对端运行的是模型代码。 5. **强制独立预算。** `computeMs` 计量 worker 忙碌时间,允许慢速的 awaited 工具而不放过热循环。`maxWallMs` 约束总经过时间,包括未解析的等待。`maxOutputBytes` 只约束序列化后的外层日志、完成值或诊断的组合;中间绑定值没有字节数上限。到期、取消和完成都终止 worker,堆退出或外层溢出会作为显式失败报告。 6. **dispose(资源释放)至完全停稳**:服务自身的 dispose 终止进行中的 worker 并*等待*其退出后再 resolve,遵循[防御性模式](../../../../docs/defensive-patterns.zh.md)。 ### 信任姿态 -worker 运行时只能约束程序的运行,而不构成安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。Code Mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 +worker 运行时只能约束程序的运行,而不构成安全边界:模型代码可以访问 Node API,权限与 bash 工具相当。`worker.terminate()` 停止线程但不停止它 spawn 的 OS 进程。PTC mode 使用与 bash 相同的 `tools/pre-execute` 策略门禁,并额外提供空环境、堆限制、独立 isolate 和对程序本身的硬终止。需要硬多租户边界的部署需要为代码和 bash 都使用容器级后端;运行时的 isolation 描述符让它们能区分该后端。 ### 模型看到的内容 -SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-code-mode-language-dispatch.zh.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一约定:相互独立的只读调用可以(MAY)在 `Promise.all`(TypeScript)或 `asyncio.gather`(Python)下重叠,有副作用的调用按提交顺序单独运行,有依赖的工作用 `await` 排序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 +SDK 指示模型编写一个所加载运行时语言的异步函数体(默认可擦除 TypeScript;Python 运行时下为 Python `async` 函数体——见[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md)),通过 `await tools.name(args)` 调用工具,在需要时捕获被拒绝的工具调用,并仅 return 或 log 应重新进入上下文的输出。两种 flavor 用各自的原语陈述同一约定:相互独立的只读调用可以(MAY)在 `Promise.all`(TypeScript)或 `asyncio.gather`(Python)下重叠,有副作用的调用按提交顺序单独运行,有依赖的工作用 `await` 排序。声明前缀可能与原生 schema 一样大,尤其在 `'both'` 下,但对提供方缓存保持稳定。 传输自身的 `description` 与两种 flavor 的 SDK 说明都以点名 `code` 和 `description` 这两个必填参数开头。把该调用描述成「传入一个程序」的散文会让第二个参数只能从参数 schema 中发现,而只发出 `{code}` 的模型会因 `INVALID_ARGS` 被拒,连同已写好的整个程序一起丢失。 @@ -97,8 +97,8 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 - **Worker 运行时:** 真实 worker 测试覆盖类型化的绑定值与失败、每一种无损 JSON 根类型的完成值、无效和超限输出、精确的组合账本边界、compute 和 wall 预算、恶意绑定流量、空环境以及 dispose 至完全停稳。一个构建后包测试在纯 Node 下运行 worker 入口。 - **注册表集成:** 测试覆盖代码生成、所有呈现模式、保留名称和限制规则、scoped 可见性、权威组装重写、`toolOrder`、运行时兼容性失败、完整流水线子分发、parent-token 关联、序列化、取消和队列排空、JSON 规范化、错误传播、日志事件、成功与失败程序中的有序上下文延后、外层阻止抑制以及 HMR(热模块替换)清理。 -- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 Code Mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 -- **快照:** `code-mode-turn`、`both-mode-turn` 和 `code-mode-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 +- **带密钥 e2e:** 真实模型在一个程序中组合两次 bash 调用;另一个模型通过 PTC mode fs 分发发现嵌套的工作区指令。测试验证折叠的请求头、关联的分发事件、结果文件、延后上下文和模型行为。 +- **快照:** `ptc-turn`、`both-mode-turn` 和 `ptc-workspace-context` fixture(测试前置数据)固定 SDK 文本、请求头工具列表、分发事件、延后上下文和结果卡片。 ## 曾考虑的替代方案 @@ -106,9 +106,9 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **`node:vm` 作为参考运行时,加固推迟。** 否决:`node:vm` 不是隔离(原型链逃逸可达宿主 realm)且无法中断热循环。worker 线程提供独立 isolate、空环境、`resourceLimits` 和可靠的 `terminate()`,信任等级等同于 bash,因此参考实现和生产实现是同一个包,无需 unsafe-acknowledgement 仪式。 -**在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 Code Mode 下为残余的原生调用分层。 +**在原生工具调用上做结果省略/摘要。** 仅解决问题中上下文膨胀这一半:裁剪旧 `tool-result` 作为可重建请求下的日志化表面替换成本低,但仍需每次调用一次模型往返,且无法表达循环、分支或汇合。互补而非竞争;它可以在 PTC mode 下为残余的原生调用分层。 -**循环中的并行原生分发。** 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。Code Mode 的队列决策保持了两者兼容,两者后来都已交付:元数据即 `isConcurrencySafe`(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.zh.md)),原生 rolling-pool 分发加每工具绑定并行化则基于同一个分类器。 +**循环中的并行原生分发。** 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。PTC mode 的队列决策保持了两者兼容,两者后来都已交付:元数据即 `isConcurrencySafe`(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.zh.md)),原生 rolling-pool 分发加每工具绑定并行化则基于同一个分类器。 **始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。 @@ -126,10 +126,10 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **SDK 的提示词成本,尤其在 `'both'` 下。** `.d.ts` 可能与它补充的原生 schema 体量相当;`'both'` 携带两种表示。前缀稳定性 + 提供方缓存摊销了每会话成本;mode 按部署配置;本 Agent Note 不做无条件节省的声明。何时优先使用哪种模式的量化指导明确属于上线后学习。 -**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内模块把这些职责分开(`ts-types.ts`、`code-mode.ts` 与 `schema.ts`、`json-schema.ts`、`presentation.ts` 并列),所有 code-runtime 专用实现都由 `ctx.codeRuntime` 提供。 +**注册表 scope 增长。** `dsh-tools` 吸收了代码生成、一个工具、一个桥和一个事件。包内模块把这些职责分开(`ts-types.ts`、`ptc.ts` 与 `schema.ts`、`json-schema.ts`、`presentation.ts` 并列),所有 code-runtime 专用实现都由 `ctx.codeRuntime` 提供。 **大型无损 JSON 值可能耗尽内存。** 工具绑定会在分发前对无损 JSON 创建快照,并完整返回规范 JSON 返回值。运行时会校验 worker 端口两侧,但不对单次绑定设置字节数上限;结构化克隆成本以及进程或 worker 内存构成实际边界。只有包含日志、完成值和失败诊断的组合外层输出账本受字节数上限约束。 -**子分发的重叠由工具自身的安全声明限定,而非由调用方决定。** 程序里的 `Promise.all` 或 `asyncio.gather` 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实约定。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 负责。 +**子分发的重叠由工具自身的安全声明限定,而非由调用方决定。** 程序里的 `Promise.all` 或 `asyncio.gather` 只在工具自己分类为并发安全的调用之间换来挂钟并行性;一串 exclusive 调用仍要按顺序付出各自的往返开销,模型可能过度期望。两种 flavor 的 SDK 说明都陈述了真实约定。本 note 交付的是使该风险绝对化的序列化占位实现;调度器及其重叠上限由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责。 **预算计量读取事件循环,而非 flag。** 忙碌时间轮询(`eventLoopUtilization()`)比精确 CPU 计量更粗糙——预算到期最多延迟一个轮询间隔——且其正确性声明(「pending 的分发不能暂停它」)是抵御恶意程序的关键。两种情况均有单元测试(带 pending 诱饵分发的热循环会在耗尽 `computeMs` 预算时终止;等待慢速绑定的空闲程序则会持续运行至 `maxWallMs`),轮询间隔是内部常量而非配置——部署无法将其误调为绕过手段。`maxWallMs` 是配置项,且会传入 `setTimeout`,后者会把超过 `MAX_TIMER_DELAY_MS`(2^31-1 ms)的延迟夹到 1 ms;因此仅有正数校验会放行一个 25 天的上限,它在第一个 tick 就到期,使每次运行都超时。worker 运行时正因如此在加载时对该字段做范围校验。`computeMs` 不需要上界,因为它对照的是实测占用率,而不是交给定时器。 diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml index 1242784a2c..75cc5ae485 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.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-06-17-filesystem-tool-schemas.md -2026-06-17-filesystem-tool-schemas.md: 7680b1200314b5cfe89f8243e2690c748d8c00fe -2026-06-17-filesystem-tool-schemas.zh.md: d75c3f83d7be6dde2c9b70a40411433c581ce830 +2026-06-17-filesystem-tool-schemas.md: 889129682f876a04c8ff7be0ce9118cc7b721e55 +2026-06-17-filesystem-tool-schemas.zh.md: 704bd4692bec760fa9ec8d01489ea48d67363d0d diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md index 7680b12003..889129682f 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md @@ -90,7 +90,7 @@ The following are deliberately out of scope for the first filesystem schema pass - Directory listing, glob, grep, and search tools. - Binary-safe read/write operations. - PDF/image/multimodal `read`. -- Code Mode projection values for filesystem tools. +- PTC mode projection values for filesystem tools. - A canonical edit diff format. ## Testing diff --git a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md index d75c3f83d7..704bd4692b 100644 --- a/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md +++ b/.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.zh.md @@ -90,7 +90,7 @@ schema 不将 `expected_hash`、`expected_version` 或 `create_only` 作为面 - 目录列表、glob、grep 和搜索工具。 - 二进制安全的读/写操作。 - PDF/图片/多模态 `read`。 -- 文件系统工具的 Code Mode 投影值。 +- 文件系统工具的 PTC mode 投影值。 - 规范的 edit diff 格式。 ## 测试 diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml b/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml index 01d5ed79ff..98ac192fd7 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.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-06-24-workspace-context.md -2026-06-24-workspace-context.md: b9009bd482b23e77bf60a9f45b228258a8d22343 -2026-06-24-workspace-context.zh.md: 96df776d565f064382587c64adb54f4562c98668 +2026-06-24-workspace-context.md: b676ab146b1449d15cfd20ff667899745344c0bf +2026-06-24-workspace-context.zh.md: e75adf39f3000e36aa2360b0225828cb5262a88c diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md index b9009bd482..b676ab146b 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.md @@ -38,7 +38,7 @@ The baseline is a user-role `` with `Instructions from: ` ### Dynamic Discovery And Refresh -After a successful first-party `read`, `write`, or `edit` call, the immutable `tools/result` observer reconciles the touched descendant chain and every scope already known to the session, then queues an `Additional instructions from: ` system-reminder in the agent inbox for the next request. Under Code Mode, successful sub-dispatch touches bubble through opaque parent execution tokens until the top-level result settles. A touch produced inside an agent-loop step does not begin its asynchronous projection until the durable `step/end`; a direct tool execution outside an open step projects immediately. The two boundaries keep result and step adjacency deterministic without making the tool pipeline await filesystem discovery. +After a successful first-party `read`, `write`, or `edit` call, the immutable `tools/result` observer reconciles the touched descendant chain and every scope already known to the session, then queues an `Additional instructions from: ` system-reminder in the agent inbox for the next request. Under PTC mode, successful sub-dispatch touches bubble through opaque parent execution tokens until the top-level result settles. A touch produced inside an agent-loop step does not begin its asynchronous projection until the durable `step/end`; a direct tool execution outside an open step projects immediately. The two boundaries keep result and step adjacency deterministic without making the tool pipeline await filesystem discovery. A content edit appends `Updated instructions from: `, states that the new content replaces the previous content, and includes the complete current file. If precedence changes from one candidate to another, the message also names the previous path and says it no longer applies. If no candidate remains, the plugin appends `Instructions removed: ` and states that the previously loaded instructions no longer apply. diff --git a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md index 96df776d56..e75adf39f3 100644 --- a/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md +++ b/.agents/notes/implemented/feature/2026-06-24-workspace-context.zh.md @@ -38,7 +38,7 @@ Status: implemented ### 动态发现与刷新 -第一方 `read`、`write` 或 `edit` 调用成功后,不可变的 `tools/result` 观察器会协调被触及的后代路径链,以及该会话已经知道的每个作用域,然后在 agent inbox 中排入一条 `Additional instructions from: ` system-reminder,供下一次请求使用。在 Code Mode 下,成功的子分派 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定。在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影;打开的步骤之外直接执行工具时,则立即投影。这两个边界在不让工具流水线等待文件系统发现的前提下,保证结果/步骤的相邻关系具有确定性。 +第一方 `read`、`write` 或 `edit` 调用成功后,不可变的 `tools/result` 观察器会协调被触及的后代路径链,以及该会话已经知道的每个作用域,然后在 agent inbox 中排入一条 `Additional instructions from: ` system-reminder,供下一次请求使用。在 PTC mode 下,成功的子分派 touch 会沿不透明的父级执行 token 逐层上浮,直到顶层结果落定。在 agent loop 步骤内产生的 touch,须等持久 `step/end` 后才开始异步投影;打开的步骤之外直接执行工具时,则立即投影。这两个边界在不让工具流水线等待文件系统发现的前提下,保证结果/步骤的相邻关系具有确定性。 内容编辑会追加 `Updated instructions from: `,说明新内容取代先前内容,并包含当前的完整文件。如果优先级从一个候选项变为另一个,消息还会指出先前路径并说明它不再适用。如果没有候选项保留,插件会追加 `Instructions removed: `,并说明先前加载的指令不再适用。 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml index be71df12c0..0f1df83a62 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.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-06-30-hook-bridges.md -2026-06-30-hook-bridges.md: c78460808c029b432f2d556485b0f9069dae4c4a -2026-06-30-hook-bridges.zh.md: 5499d641cacd3af764d2be7c450f4fc92b15e078 +2026-06-30-hook-bridges.md: 30d481f8bda2c65433863e55a75c862cc42479f0 +2026-06-30-hook-bridges.zh.md: 5d994bb0634aeca774d2265e1eba8487e8011ce2 diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md index c78460808c..30d481f8bd 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.md @@ -41,7 +41,7 @@ Every bridge `inject()` and additional-context input explicitly passes `{ kind: ### Adding context is not a veto — delegate, then prepend -A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `enter` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/pre-step` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to a downstream enter decision. The bridge preserves every downstream message, while a downstream pre-step rejection drops the whole claimed batch because no step opens. Post-tool decisions retain their independent ordered `additionalContexts` semantics, including Code Mode deferral through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still reject a prompt after a context-only hook and that retained prompt and post-tool contexts remain separate. +A hook that only attaches `additionalContext` (no block/deny) is NOT a decision the bridge should return on its own: returning `enter` from a waterfall listener WITHOUT calling `next()` short-circuits every later `agent/pre-step` / `tools/post-execute` listener, so a policy/sandbox plugin registered after the bridge would never see the prompt. Each bridge therefore delegates via `next()` before adding its context to a downstream enter decision. The bridge preserves every downstream message, while a downstream pre-step rejection drops the whole claimed batch because no step opens. Post-tool decisions retain their independent ordered `additionalContexts` semantics, including PTC mode deferral through the outer `run_code` result. Only a real `deny`/`block` from the hook itself short-circuits. Tests assert a later listener can still reject a prompt after a context-only hook and that retained prompt and post-tool contexts remain separate. ### CLAUDE_PROJECT_DIR defaults to the session workspace diff --git a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md index 5499d641ca..5d994bb063 100644 --- a/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md +++ b/.agents/notes/implemented/feature/2026-06-30-hook-bridges.zh.md @@ -41,7 +41,7 @@ CC 桥接的 `ask` 结果是一条真正的权限路径,而非终态桥接决 ### 添加上下文不是否决——先 delegate,再 prepend -仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall(瀑布式事件)监听器中不调用 `next()` 就返回 `enter`,会短路其后的每个 `agent/pre-step` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游 enter 决策。桥接会保留所有下游消息;下游 pre-step reject 会丢弃整个已领取批次,因为步骤从未打开。工具后决策仍保留独立的有序 `additionalContexts` 语义,包括 Code Mode 通过外层 `run_code` 结果延迟上下文。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:仅上下文钩子之后,较晚的监听器仍能 reject 提示词,且保留的提示词和工具后上下文仍彼此分离。 +仅附加 `additionalContext`(没有 block/deny)的钩子并不是桥接可以独自返回的决策:在 waterfall(瀑布式事件)监听器中不调用 `next()` 就返回 `enter`,会短路其后的每个 `agent/pre-step` / `tools/post-execute` 监听器,使注册在桥接之后的策略/沙箱插件看不到该提示词。因此,每个桥接都会先通过 `next()` 委托,再将自身上下文加入下游 enter 决策。桥接会保留所有下游消息;下游 pre-step reject 会丢弃整个已领取批次,因为步骤从未打开。工具后决策仍保留独立的有序 `additionalContexts` 语义,包括 PTC mode 通过外层 `run_code` 结果延迟上下文。只有钩子本身真正返回 `deny`/`block` 才会短路。测试断言:仅上下文钩子之后,较晚的监听器仍能 reject 提示词,且保留的提示词和工具后上下文仍彼此分离。 ### CLAUDE_PROJECT_DIR 默认为会话工作区 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index 5ec6161319..e32202b83d 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.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-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: cde9cf1130d6b6b971ddf35c64e34ad6d9fb7dee -2026-07-07-mcp-client-plugin.zh.md: 0cf69a97bc714efcc4083fc2b37519dfc10cdf32 +2026-07-07-mcp-client-plugin.md: 59983abc6288534a82aef903a8c6bed93db24be6 +2026-07-07-mcp-client-plugin.zh.md: 791b6f9eb10bef6a481caa106a0bda31b803dc4c diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index cde9cf1130..59983abc62 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -141,10 +141,10 @@ Tools are never silently skipped; which tools are available never depends on plu A unified `execute` handler for all tools from one MCP server: 1. Resolve `rawName` (the executor closes over it) and call `client.callTool({ name: rawName, arguments }, { signal: exec.signal })` with the configured timeout — the public name is never sent to the server. -2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/Code Mode value. `isError: true` throws before any image persistence so the registry owns the failure path. +2. Preserve canonical success as `{ content: JsonValue[], structuredContent? }`; complete MCP JSON blocks remain the programmatic/PTC mode value. `isError: true` throws before any image persistence so the registry owns the failure path. 3. Prepare a separate ordered Native projection. Text runs join with `'\n'`; resource links preserve name and URI as text; audio, embedded resources, malformed blocks, and unknown types become explicit diagnostics. If any image exists, the bridge strictly decodes the complete batch, resolves the calling agent's latest exact route, requires an attachment store plus explicit model image input, and delegates all-member validation and ordered persistence to `AttachmentStore.saveImages()`. Any decode, capability, or storage refusal renders every image as diagnostic text and returns no partial references. 4. Keep `output.render` synchronous and pure. The executor stages its richer projection in a generation-local `WeakMap` keyed by the exact execution; `finalizeContent` installs it only when the registry's post-execute result still has the original canonical value and fallback content. A policy block, value replacement, or content replacement remains authoritative, and a re-sync cannot let an older generation consume new execution state. -5. Code Mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case. +5. PTC mode receives the untouched canonical value. Its generic dispatch bridge defers a successful final content sequence containing an image through the outer `run_code` result, so MCP requires no private parent-token special case. 6. Cancellation: `exec.signal` (from the agent loop's cancel) is passed through to the MCP SDK's `callTool`, exact-model lookup, and the pre-storage gate. ### Subprocess environment (stdio transport) @@ -197,9 +197,9 @@ Rejected. Programmatic callers need protocol-complete MCP blocks and `structured Rejected. Core already owns the role-neutral content vocabulary, and a second service would duplicate its logging and ordering contracts. `output.render` is pure, synchronous, and replayable, so attachment I/O belongs in async execution with an exact finalization handoff. -### Let each image-returning tool special-case Code Mode parents +### Let each image-returning tool special-case PTC mode parents -Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic Code Mode bridge observes the final post-policy content and forwards image-bearing results uniformly. +Rejected. That couples leaf tools to composite-tool internals and misses future rich tools. The generic PTC mode bridge observes the final post-policy content and forwards image-bearing results uniformly. ## Testing @@ -207,7 +207,7 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex - **Unit** (`tests/mcp-client.spec.ts`, `tests/apply.spec.ts`, mocked MCP SDK): the `publicToolName` algorithm (clean, normalize, truncate-and-hash, determinism, distinct-identity separation), raw-vs-public wire discipline, cross-server and native-tool coexistence, duplicate-`serverName` load failure and reservation release, invalid-tool-list rejection, generation swap/rollback, failed-re-sync retention, lossless canonical results, mixed rich ordering, atomic malformed batches, exact capability/store refusal, explicit non-image diagnostics, post-execute policy precedence, cancellation, and config schema validation. 100% per-file coverage gates the package. - **E2E** (`tests/mcp-client.e2e.ts`, keyless): the real MCP protocol against the in-repo fixture server, `@modelcontextprotocol/server-everything`, and `@modelcontextprotocol/server-filesystem` over stdio, and against an in-process `StreamableHTTPServerTransport` server over Streamable HTTP — discovery under the namespace, dotted-name normalization end to end, execution round-trips, durable image save/read with base64 retained only in the canonical value, explicit refusal without an image route, duplicate-`serverName` rejection, and disposal. -- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the Code Mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot. +- **Snapshot**: the assembled ACP example owns the transport-visible inline-image transcript and the PTC mode image-forwarding transcript; package E2E owns the real MCP wire because the runnable snapshot must stay keyless and deterministic rather than spawning third-party server packages. MCP tool cards still use the generic-card fallback and require no package-specific UI snapshot. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 0cf69a97bc..791b6f9eb1 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -141,10 +141,10 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 为来自同一个 MCP 服务器的所有工具提供统一的 `execute` 处理器: 1. 解析 `rawName`(执行器闭包持有它),以配置的超时时间调用 `client.callTool({ name: rawName, arguments }, { signal: exec.signal })`——公开名称永远不发送给服务器。 -2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/Code Mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。 +2. 把规范成功值保留为 `{ content: JsonValue[], structuredContent? }`;完整 MCP JSON 块仍是程序化调用/PTC mode 值。`isError: true` 会在持久化任何图片前抛出,使失败路径归注册表所有。 3. 另行准备有序 Native 投影。连续文本块以 `'\n'` 连接;资源链接以文本保留名称和 URI;音频、嵌入资源、格式错误的块和未知类型成为明确诊断。只要存在图片,桥接层就严格解码完整批次,解析调用 agent 的最新确切路由,要求附件存储以及模型明确支持图片输入,再把全成员校验和有序持久化委托给 `AttachmentStore.saveImages()`。任何解码、能力或存储拒绝都会把全部图片渲染为诊断文本,且不返回部分引用。 4. 保持 `output.render` 同步且纯净。执行器把更丰富的投影暂存在按同步世代创建、以确切执行为键的 `WeakMap` 中;只有注册表的 post-execute 结果仍保留原规范值和兜底内容时,`finalizeContent` 才安装该投影。策略阻止、值替换或内容替换仍具有权威性,重新同步也无法让旧世代消费新执行状态。 -5. Code Mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。 +5. PTC mode 接收未改动的规范值。其通用分发桥接层会把包含图片的成功最终内容序列经外层 `run_code` 结果延后,因此 MCP 无需私有父 token 特例。 6. 取消:`exec.signal`(来自 agent loop 的取消)透传给 MCP SDK 的 `callTool`、确切模型查询和存储前门禁。 ### 子进程环境(stdio 传输) @@ -197,9 +197,9 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp 不予采用。核心已经拥有角色无关的内容词汇,第二套服务会重复其日志与顺序契约。`output.render` 必须纯净、同步且可回放,因此附件 I/O 属于异步执行,再经确切的最终化交接安装结果。 -### 让每个返回图片的工具分别特殊处理 Code Mode 父调用 +### 让每个返回图片的工具分别特殊处理 PTC mode 父调用 -不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 Code Mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。 +不予采用。这会把叶子工具与组合工具内部机制耦合,并漏掉未来丰富工具。通用 PTC mode 桥接层观察最终 post-policy 内容,统一转发含图片结果。 ## 测试 @@ -207,7 +207,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp - **单元测试**(`tests/mcp-client.spec.ts`、`tests/apply.spec.ts`,mock MCP SDK):`publicToolName` 算法(干净名称、规范化、截断加 hash、确定性、不同标识的分离)、raw 与 public 的协议纪律、跨服务器与原生工具共存、重复 `serverName` 加载失败与预留释放、无效工具列表拒绝、注册代切换/回滚、重新同步失败时保留上一代注册、无损规范结果、丰富内容混合顺序、格式错误批次原子性、确切能力/存储拒绝、明确的非图片诊断、post-execute 策略优先级、取消,以及配置 schema 校验。100% 逐文件覆盖率门禁约束该包。 - **E2E**(`tests/mcp-client.e2e.ts`,无需密钥):使用真实 MCP 协议对接仓库内的 fixture(测试前置数据)服务器、`@modelcontextprotocol/server-everything` 和 `@modelcontextprotocol/server-filesystem`(stdio 传输),以及进程内 `StreamableHTTPServerTransport` 服务器(Streamable HTTP 传输)——命名空间下的发现、带点号名称的端到端规范化、执行往返、持久图片保存/读取且 base64 只保留在规范值中、缺少图片路由时明确拒绝、重复 `serverName` 拒绝,以及 dispose。 -- **快照**:组装后的 ACP 示例负责传输可见的内联图片 transcript 与 Code Mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照。 +- **快照**:组装后的 ACP 示例负责传输可见的内联图片 transcript 与 PTC mode 图片转发 transcript;包 E2E 负责真实 MCP 协议,因为可运行快照必须保持无密钥且确定,而不是 spawn 第三方服务器包。MCP 工具卡片仍使用通用卡片兜底,无需包专属 UI 快照。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index c3d47a1d34..3c267eaf73 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.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-10-parallel-tool-call-execution.md -2026-07-10-parallel-tool-call-execution.md: 81690377e22966eb28ef850a41a4c05c6fa534f0 -2026-07-10-parallel-tool-call-execution.zh.md: 3cee5533b7686f461548b97880e594fffee234d2 +2026-07-10-parallel-tool-call-execution.md: 870ab3bd6f48e7e2aad63baafb55020fd4c6ccd5 +2026-07-10-parallel-tool-call-execution.zh.md: ccab1d9b912ce6332bb493859bcefdb4b173b0d3 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 81690377e2..870ab3bd6f 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -48,7 +48,7 @@ Each started call appends `tool/call` immediately before its pre-execute gate. C An abort before a group starts records no calls from that group. An abort during a group stops replenishment, waits for already-started calls, commits their results in order, drains accepted batch context after those results, and then ends the step through the existing abort path. Calls that never start have no audit event. An unexpected scheduler failure stops new dispatches, waits for every already-started dispatch to settle, and rethrows the first failure. Because that failure is terminal internal state rather than a tool outcome, the loop does not invent tool results for rejected or uncommitted calls. -Code Mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. +PTC mode remains outside this scheduler because the model emits one native `run_code` call. `run_code` and its internal dispatch queue remain serial; native sibling calls in `mode: 'both'` use the normal scheduler. ## Safety contract @@ -60,7 +60,7 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../../docs/config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. +The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, PTC mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. @@ -68,7 +68,7 @@ Filesystem read relies on a narrow recorder exception: its synchronous observati Unit coverage pins fail-closed classification, typed argument validation, grouping, barriers, live reclassification after registry replacement, the rolling cap, distinct execution objects, middleware order, ordered results and context, abort draining, and scheduler-failure quiescence. First-party tests pin each parallel declaration. -Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. Code Mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. +Snapshot coverage pins the visible multi-call transcript: pending calls may overlap while completed results remain model-ordered. PTC mode coverage pins its serial boundary. No provider-backed e2e is required because scheduling is deterministic loop behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index 3cee5533b7..ccab1d9b91 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -48,7 +48,7 @@ Status: implemented 如果在一组启动前中止,系统不会记录该组的任何调用。如果在一组执行期间中止,系统会停止补充池,等待已启动的调用,按顺序提交其结果,在这些结果之后排空已接受的批次上下文,然后通过现有中止路径结束该步骤。从未启动的调用没有审计事件。调度器发生意外故障时,会停止新的派发,等待每项已启动的派发结算,并重新抛出第一个故障。由于该故障是内部终态,而非工具结果,循环不会为被拒绝或未提交的调用虚构工具结果。 -Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 +PTC mode 仍不使用此调度器,因为模型只会发出一个原生 `run_code` 调用。`run_code` 及其内部派发队列仍按串行方式执行;`mode: 'both'` 中的原生并列调用使用常规调度器。 ## 安全约定 @@ -60,7 +60,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c `maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.zh.md)。 -当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.zh.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 +当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.zh.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、PTC mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 @@ -68,7 +68,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c 单元测试固定了按安全侧原则进行的分类、类型化参数验证、分组、屏障、注册表替换后的运行时重新分类、滚动上限、独立执行对象、中间件顺序、有序结果与上下文、中止排空,以及调度器故障后的完全停稳。第一方测试固定了每项并行声明。 -快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。Code Mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 +快照测试固定了可见的多调用 transcript(文本记录):待处理调用可以重叠执行,已完成结果仍按模型顺序排列。PTC mode 测试固定了其串行边界。此调度属于确定性循环行为,因此无需依赖提供方的 e2e 测试。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml index 927c70ef33..c13694c791 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.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-12-subagent-persona-tool-filter-and-depth.md -2026-07-12-subagent-persona-tool-filter-and-depth.md: 4e4fa3c0039004c50f9c803a951253a7f603f909 -2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: e2fb1508d780d39f587d279ab097f86ed7602c80 +2026-07-12-subagent-persona-tool-filter-and-depth.md: 7ba9768df3679da6b07728cf64237c47d4c73b2f +2026-07-12-subagent-persona-tool-filter-and-depth.zh.md: d3a8240542d896a27e82b1be1b491c241003e27e diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md index 4e4fa3c003..7ba9768df3 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.md @@ -34,7 +34,7 @@ This uses the normal system-prompt registration mechanism rather than a second p ### Tool filtering is one live global-view rule -The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and Code Mode SDK generation. Independently registered system-prompt sections are outside `ToolRuntime`, so filtering a tool does not remove that plugin's standalone guidance. +The tool filter controls capability visibility and executable lookup together. An in-process provider installs `ToolRuntime.restrict()` in the child's scope before publication, and the registry's single resolver applies the same result to wire tool schemas, lookup, execution, and PTC mode SDK generation. Independently registered system-prompt sections are outside `ToolRuntime`, so filtering a tool does not remove that plugin's standalone guidance. Resolution follows these rules: @@ -85,7 +85,7 @@ A security design would need a separate authority representation, propagation ru **Snapshot allowed global tools at child creation.** A frozen allow-set makes future registration uniformly unavailable, but it changes hot-registration semantics and starts an authorization design. The implemented filter stays a live registry predicate and documents allow-versus-deny behavior directly. -**Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through Code Mode or a forged call. One resolver governs both presentation and execution instead. +**Hide only tool schemas.** Presentation-only filtering lets the model execute a tool that the prompt says does not exist through PTC mode or a forged call. One resolver governs both presentation and execution instead. **Encode the depth cap as an automatic tool filter.** A creation-time filter snapshots a decision that may depend on runtime state, affects only one configured tool name, and does not protect direct service callers or alternate delegation tools. The provider instead enforces the absolute cap at every start. diff --git a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md index e2fb1508d7..d3a8240542 100644 --- a/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md +++ b/.agents/notes/implemented/feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md @@ -36,7 +36,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma ### 工具过滤是一条作用于实时全局视图的规则 -工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 Code Mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRuntime` 内,因此过滤一个工具不会移除该插件的独立指导文本。 +工具过滤同时控制能力可见性和可执行查找。进程内提供方在发布前于子 agent 作用域中安装 `ToolRuntime.restrict()`,注册表的单一解析器对协议格式(wire format)的工具 schema、查找、执行和 PTC mode SDK 生成施加相同的结果。独立注册的系统提示词段落不在 `ToolRuntime` 内,因此过滤一个工具不会移除该插件的独立指导文本。 解析遵循以下规则: @@ -87,7 +87,7 @@ subagent 启动有三个独立的组合控制:`persona`、`toolFilter` 和 `ma **在子 agent 创建时快照允许的全局工具。** 冻结的 allow 集合使未来注册统一不可用,但它改变了热注册语义并开启了授权设计。已实现的过滤器保持为活跃的注册表谓词,并直接记录 allow 与 deny 的行为。 -**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 Code Mode 或伪造调用执行一个提示词声称不存在的工具。改为由一个解析器同时管控呈现和执行。 +**仅隐藏工具 schema。** 仅呈现层的过滤让模型可以通过 PTC mode 或伪造调用执行一个提示词声称不存在的工具。改为由一个解析器同时管控呈现和执行。 **把深度上限编码为自动工具过滤器。** 创建时过滤器会快照一个可能依赖运行时状态的决策,只影响一个已配置工具名,且不保护直接服务调用方或替代委派工具。提供方改为在每次启动时强制绝对上限。 diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml deleted file mode 100644 index 0ed1b87308..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/feature/2026-07-20-code-mode-typed-tool-returns.md -2026-07-20-code-mode-typed-tool-returns.md: a8a251f5f0d39f4deedc42e08eb45c2b5fa11807 -2026-07-20-code-mode-typed-tool-returns.zh.md: 2589d63d3fb900c88fb15ab68107e75735d1be44 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 cb77a58801..c7a1607d19 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: 8e9604072464f2b5adbd3b2058c54fab732d06d6 -2026-07-20-dsh-cli-personal-config.zh.md: 4863b5dbe19c125ab0b9829ffbffb673ea8d5961 +2026-07-20-dsh-cli-personal-config.md: db1e5638f06b66f74d4761d1eab4e5248e6def0b +2026-07-20-dsh-cli-personal-config.zh.md: d796d9887421aa7c8e9f3e8d1de66c74e10c139d 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 8e96040724..db1e5638f0 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 @@ -42,7 +42,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. - An installed `dsh` command can run from any directory, while source users invoke `pnpm dsh` from the checkout; both can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit. The behavior was verified end to end against a personal Anthropic proxy with Opus 4.8, including a bash tool round trip. - Because an id-targeted patch replaces the whole `config`, a personal override restates the base fields it keeps and can drift when the base entry changes shape; the loader's entry-not-found/name-mismatch warnings and [`dsh --dump-config`](../../../../apps/cli/README.md#profiles) (which prints the composed tree those patches produce) are the diagnostics. -- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (Code Mode) are not personalized; live-run parity for those leaves is deferred. +- Personal patches resolve ids against the booted file's own tree, so nested-include overlays (PTC mode) are not personalized; live-run parity for those leaves is deferred. - `dsh-app-boot` depends on `js-yaml` and imports the include's `!!js` YAML dialect (`entryListSchema`) directly, and, like `apps/cli`, depends on `@deepseek-ai/dsh-home-paths` for `resolveDshHome`. - Live watching belongs only to long-running TUI and Web processes. Headless automation gets deterministic startup configuration and exits without retaining a watcher. 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 4863b5dbe1..d796d98874 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 @@ -42,7 +42,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 - 已安装的 `dsh` 命令可从任意目录运行,源码用户则从 checkout 调用 `pnpm dsh`;两者都无需修改 checkout 即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项。该行为已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.zh.md#profiles)(打印这些补丁合成出的配置树)。 -- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 +- 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(PTC mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-home-paths` 以获取 `resolveDshHome`。 - 只有长时间运行的 TUI 和 Web 进程进行实时监视。无头自动化使用确定性的启动配置,退出时不会保留 watcher。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml similarity index 57% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml rename to .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml index 34b44986bf..249e799554 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md -2026-07-26-code-dispatch-ui-foundation.md: 2b919dfb978ef3df0e65d2e0e410e2bd07c75a46 -2026-07-26-code-dispatch-ui-foundation.zh.md: 33599c5689973d05ca6ec98fbf919cea6fc1cd4d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md +2026-07-20-ptc-typed-tool-returns.md: 113ea2c305984ce95b0d85d70f6773abbc0929db +2026-07-20-ptc-typed-tool-returns.zh.md: 13b2f6e381a30b4db1cd818ab47d3e30c8e0c84a diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md similarity index 73% rename from .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md rename to .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md index a8a251f5f0..113ea2c305 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md @@ -1,22 +1,22 @@ -# Agent Note: Typed tool returns in Code Mode +# Agent Note: Typed tool returns in PTC mode Status: implemented -English | [中文](2026-07-20-code-mode-typed-tool-returns.zh.md) +English | [中文](2026-07-20-ptc-typed-tool-returns.zh.md) ## Problem -Code Mode originally projected each nested tool result back from `ContentBlock[]` into one string. That preserved the human-readable Native presentation but erased the canonical result the tool had already produced: programs had to scrape job ids and dynamic mount ids from prose, structured search and workflow results lost their shape, and non-text blocks became placeholders. The generated SDK could describe arguments but could only promise `Promise` regardless of the tool's real output. +PTC mode originally projected each nested tool result back from `ContentBlock[]` into one string. That preserved the human-readable Native presentation but erased the canonical result the tool had already produced: programs had to scrape job ids and dynamic mount ids from prose, structured search and workflow results lost their shape, and non-text blocks became placeholders. The generated SDK could describe arguments but could only promise `Promise` regardless of the tool's real output. The runtime also treated binding values and the final program value as presentation data. Separate log and completion caps could replace an oversized or non-cloneable completion with inspected text even though intermediate values do not enter model context. That made programmatic composition lossy and confused the memory boundary with the prompt boundary. -The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) establishes one validated execution-time value and a separate Native renderer. Code Mode should consume that value directly, preserve it across the worker boundary, and bound only the final output the program deliberately returns to the model. +The [canonical tool-output contract](../architecture/2026-07-20-canonical-tool-output-contract.md) establishes one validated execution-time value and a separate Native renderer. PTC mode should consume that value directly, preserve it across the worker boundary, and bound only the final output the program deliberately returns to the model. ## Decision -Code Mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. The outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and model-facing spill pipeline; a successfully settled sub-call whose final Native content contains an image additionally defers that complete ordered content through the parent result as logged, source-attributed context. +PTC mode is a typed projection of the visible tool registry. Each successful binding resolves to the final canonical `JsonValue` after post-execute policy, while a failed binding rejects with a real `ToolCallError`. Intermediate values remain inside the run and cross the worker boundary whole. The outer `run_code` logs, completion value, or failure diagnostic enter the configurable output ledger and model-facing spill pipeline; a successfully settled sub-call whose final Native content contains an image additionally defers that complete ordered content through the parent result as logged, source-attributed context. -This note owns the return and failure contract layered on the original [Code Mode foundation](2026-06-15-code-mode.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note. +This note owns the return and failure contract layered on the original [PTC mode foundation](2026-06-15-ptc.md). The unified schema vocabulary is owned by the [JSON-value schema DSL note](../architecture/2026-07-20-unified-json-value-schema-dsl.md), and Native rendering and policy projection remain owned by the canonical-output note. ### Generated SDK @@ -51,7 +51,7 @@ declare const tools: { Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program. Image-bearing final content is not a second binding value: the bridge ferries it after the outer result so the next model request can see the durable image, while post-execute block/content replacement remains authoritative and text-only results are not duplicated. -Code Mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime Service Definition treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. +PTC mode declares its rejection capability on the runtime request as `{ name: "ToolCallError", memberNameProperty: "toolName" }`. The runtime Service Definition treats those names as data: the worker materializes and injects the actual constructor used for `tools` binding failures, so `error instanceof ToolCallError` works without making a generic runtime know about tools. The worker constructs failures and defines their public fields through module-captured error and property-definition intrinsics plus null-prototype descriptors, so model mutations cannot replace the promised rejection with a worker failure. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification. Binding arguments and resolutions are revalidated as lossless JSON on both sides of the hostile worker protocol and have no byte cap. Before crossing through structured clone, each detached value is encoded as a flat pre-order token stream whose transport nesting is bounded; the receiver rebuilds it iteratively. Valid application nesting therefore has neither a JavaScript call-stack depth cap nor a platform-specific nested structured-clone limit. At module initialization the worker captures its own realm's `Array.prototype` and `Object.prototype` identities, the native function-source intrinsic used only to recognize foreign-realm plain-container prototypes, and every structural and metering intrinsic used by the JSON boundary. Property writes use null-prototype descriptors, while private array and set operations invoke captured methods without consulting mutable global or prototype slots. Model code can therefore replace helpers such as `Object.keys`, `Array.isArray`, collection methods, string methods, or `Buffer.byteLength`, rewrite intrinsic-prototype constructor slots, or add descriptor-shaped fields to `Object.prototype` without changing validation, wire transport, or byte accounting. The foreign-realm native function-source check still rejects user-authored constructors that imitate `Object` or `Array`. The dependency-light runtime Service Definition names its structural equivalent `CodeJsonValue` so it need not depend on the session-owned canonical type; the generated SDK and tool API use `JsonValue`. Intermediate values are not prompt-truncated, context-spilled, or persisted. This preserves full acquired search, workflow, task, filesystem, and MCP values for programmatic filtering while leaving provider and executor acquisition limits truthful. @@ -73,13 +73,13 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu ### Persistence, metadata, and spill -Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values. +Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/ptc-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values. The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`. ## Testing -Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, exotic names, and assembled Code Mode image forwarding. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; generic image-bearing context deferral plus post-execute replacement/block precedence; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. +Compile-time and snapshot tests pin exact `ToolArgsMap`, `ToolOutputMap`, `ToolName`, schema-to-TypeScript coverage, exotic names, and assembled PTC mode image forwarding. Registry and real-worker tests cover scalar, array, object, and null values; raw string rendering; absent `undefined`; consumer-declared real rejection classes, including `ToolCallError`; invalid arguments and completions, including intrinsic-looking forged prototypes; model-mutated JSON-boundary globals, prototype methods, constructor slots, and inherited descriptor fields; typed binding failures after those mutations; large uncapped intermediate bindings; nested spill suppression; generic image-bearing context deferral plus post-execute replacement/block precedence; exact and over-limit 64 MiB accounting; combined logs/value/diagnostic accounting; giant thrown stacks; bounded failure spill; hostile forged traffic; and built-package execution. Keyless real-worker integration tests pin the two handle workflows that prose results could not safely support. A background bash call returns its job id, the outer run settles, and a later run polls that id to completion; separate cases prove pre-abort creates no task, post-publication call abort preserves the task, foreground execution stays signal-coupled, and `job_kill` owns cancellation. A Cordis program reads an active or pending mount's id and `waitingFor` fields directly, unmounts by that id, and confirms removal without parsing rendered text. @@ -93,13 +93,13 @@ Keyless real-worker integration tests pin the two handle workflows that prose re **Silently inspect or truncate an oversized completion.** Rejected because changing a JSON value into a string is lossy and type-incorrect. The explicit `output-limit` failure lets the model choose a smaller result, while the retained logs and diagnostic can still use normal outer spill. -**Require each rich leaf tool to inspect `exec.parent` and defer itself.** Rejected because it couples leaf tools to Code Mode internals, duplicates policy handling, and misses future rich tools. The dispatch bridge owns generic forwarding from the already settled final result. +**Require each rich leaf tool to inspect `exec.parent` and defer itself.** Rejected because it couples leaf tools to PTC mode internals, duplicates policy handling, and misses future rich tools. The dispatch bridge owns generic forwarding from the already settled final result. **Expose Native rich content as part of every binding's canonical value.** Rejected because a canonical value is lossless JSON and tool-specific; attachment blocks are a model projection with durable lifecycle semantics. Keeping the value and projection separate preserves typed programs without dropping images from later model context. ## Consequences -Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and UI presentation, while Code Mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer. +Code programs can compose tools through stable values instead of reverse-engineering Native prose. Native and Both Mode retain their existing text and UI presentation, while PTC mode receives output-schema types and exact runtime JSON. Tool authors must treat the canonical value as their programmatic API and put display-only formatting in the renderer. The worker performs bounded-depth flat-wire transport and lossless validation but does not make intermediate values cheap or durable. Outer overflow is an explicit failed run, and error handling remains intentionally human-guided rather than a versioned code union. @@ -110,7 +110,7 @@ The worker performs bounded-depth flat-wire transport and lossless validation bu - Intermediate canonical values are execution-local and unavailable to replay because durable events persist only presentation and bounded summaries. - Intermediate values have no byte cap and can exhaust process or worker memory through retention, flat-wire copies, or structured-clone cost. - The 64 MiB hard cap applies only to the outer variable payloads, excluding fixed result-envelope syntax and presentation whitespace; spill cannot recover bytes rejected beyond that cap. -- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches Code Mode. +- Provider or executor acquisition limits may already have discarded source data before a canonical value reaches PTC mode. - Unsupported MCP output schemas fall back to `JsonValue`; admitted MCP images use the generic deferred projection, while audio and embedded-resource payloads remain diagnostic-only. - There is one result card per outer `run_code`, never per nested call. - Code failures expose `ToolCallError` message and tool name only, without a programmatic error-code union. diff --git a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md similarity index 71% rename from .agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md rename to .agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md index 2589d63d3f..13b2f6e381 100644 --- a/.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md @@ -1,22 +1,22 @@ -# Agent Note: Code Mode 的类型化工具返回值 +# Agent Note: PTC mode 的类型化工具返回值 Status: implemented -[English](2026-07-20-code-mode-typed-tool-returns.md) | 中文 +[English](2026-07-20-ptc-typed-tool-returns.md) | 中文 ## 问题 -Code Mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 呈现,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 job id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 +PTC mode 过去会把每个嵌套工具的结果从 `ContentBlock[]` 重新投影为一个字符串。这样虽然保留了适合人类阅读的 Native 呈现,却丢失了工具已经生成的规范结果:程序只能从自然语言中提取 job id 和动态挂载 id;结构化搜索与工作流结果失去原有形态;非文本块则变为占位符。生成的 SDK 可以描述参数,却无论工具实际输出为何都只能承诺 `Promise`。 运行时还把绑定值和程序最终返回值当作展示数据。日志和完成值分别设置上限,导致过大或无法克隆的完成值可能被替换为检查后生成的文本,而中间值本来就不会进入模型上下文。这种设计使程序化组合产生信息损失,也混淆了内存边界与提示词边界。 -[规范工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。Code Mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。 +[规范工具输出约定](../architecture/2026-07-20-canonical-tool-output-contract.zh.md)确立了单一、经过校验的执行期值,并将 Native 渲染器与之分离。PTC mode 应直接消费该值,在跨越 worker 边界时完整保留它,并且只限制程序有意返回给模型的最终输出。 ## 决策 -Code Mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线;如果成功结算的子调用最终 Native 内容包含图片,其完整有序内容还会经父结果延后为写入日志且带来源归属的上下文。 +PTC mode 是可见工具注册表的类型化投影。每个成功的绑定调用都会解析为 post-execute 策略处理后的最终规范 `JsonValue`,失败的绑定调用则会以真正的 `ToolCallError` 拒绝 Promise。中间值只存在于本次运行中,并完整跨越 worker 边界。外层 `run_code` 的日志、完成值或失败诊断会进入可配置的输出账本以及面向模型的输出落盘流水线;如果成功结算的子调用最终 Native 内容包含图片,其完整有序内容还会经父结果延后为写入日志且带来源归属的上下文。 -本文档定义叠加在原始 [Code Mode 基础](2026-06-15-code-mode.zh.md)之上的返回值与失败约定。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.zh.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。 +本文档定义叠加在原始 [PTC mode 基础](2026-06-15-ptc.zh.md)之上的返回值与失败约定。统一 schema 词汇由 [JSON 值 schema DSL Agent Note](../architecture/2026-07-20-unified-json-value-schema-dsl.zh.md)负责定义;Native 渲染与策略投影仍由规范输出 Agent Note 负责定义。 ### 生成的 SDK @@ -51,7 +51,7 @@ declare const tools: { 分发前,桥接层会把绑定参数快照为无损 JSON,再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前被拒绝。成功分发会返回 `ToolExecutionResult.value`;Native `content`、元数据和内部错误信息不会传入程序。含图片的最终内容不是第二份绑定值:桥接层会在外层结果之后转运它,使下一次模型请求可以看到持久图片;post-execute 阻止/内容替换仍具有权威性,纯文本结果不会重复。 -Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 Service Definition 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把约定承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常约定,而不是供程序分类的失败联合。 +PTC mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProperty: "toolName" }` 声明其以异常拒绝 Promise 的能力。运行时 Service Definition 只把这些名称视为数据:worker 会动态生成并注入真正用于 `tools` 绑定失败的构造函数,因此无需让通用运行时了解工具,`error instanceof ToolCallError` 也能成立。worker 使用模块初始化时捕获的 Error 构造函数与属性定义内建方法,配合原型为 null 的属性描述符,构造失败对象并定义其公开字段,因此模型代码的修改不会把约定承诺的 reject 变成 worker 失败。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常约定,而不是供程序分类的失败联合。 绑定参数与绑定返回值会在不可信 worker 协议的两端重新校验为无损 JSON,且不设字节上限。每个分离后的值在通过结构化克隆跨越边界前,都会编码为扁平的前序 token 流,其传输结构的嵌套深度有界;接收方再以迭代方式重建该值。因此,有效应用数据的嵌套深度既不受 JavaScript 调用栈深度上限限制,也不受特定平台对嵌套结构化克隆施加的上限限制。模块初始化时,worker 会捕获自身 JavaScript 运行域中 `Array.prototype` 和 `Object.prototype` 的引用、仅用于识别其他运行域普通容器原型、可获取原生函数源码的内建函数,以及 JSON 边界用于结构处理和计量的全部内建方法。属性写入使用原型为 null 的属性描述符;内部的数组与集合操作直接调用捕获的方法,不会访问可变的全局或原型槽位。因此,即使模型代码替换 `Object.keys`、`Array.isArray`、集合方法、字符串方法或 `Buffer.byteLength` 等辅助方法,重写内建原型的构造函数槽位,或向 `Object.prototype` 添加形如属性描述符的字段,也不会改变校验、协议传输或字节计量。面向其他运行域的原生函数源码检查仍会拒绝由用户编写、冒充 `Object` 或 `Array` 的构造函数。为保持依赖轻量,运行时 Service Definition 将结构等价类型命名为 `CodeJsonValue`,从而无需依赖会话侧拥有的规范类型;生成的 SDK 和工具 API 则使用 `JsonValue`。这些值不会经过提示词截断、上下文 spill 或持久化。因此,程序可以完整筛选已经采集的搜索、工作流、任务、文件系统与 MCP 值,同时提供方和执行器的采集上限仍会实际生效。 @@ -73,13 +73,13 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper ### 持久化、元数据与 spill -嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。 +嵌套分发在 `tool/ptc-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。 不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的 spill 投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。 ## 测试 -编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围、特殊名称,以及组装后的 Code Mode 图片转发。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;通用含图片上下文延后以及 post-execute 替换/阻止优先级;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 +编译期测试与快照测试锁定了精确的 `ToolArgsMap`、`ToolOutputMap`、`ToolName`、schema 到 TypeScript 的覆盖范围、特殊名称,以及组装后的 PTC mode 图片转发。注册表与真实 worker 测试覆盖标量、数组、对象和 null 值;字符串原文渲染;缺席的 `undefined`;消费方声明、实际用于拒绝 Promise 的异常类,包括 `ToolCallError`;无效参数与完成值,包括伪装为内建原型的伪造原型;模型代码修改过的 JSON 边界全局对象、原型方法、构造函数槽位,以及继承而来的属性描述符字段;上述修改后的类型化绑定失败;不设上限的大型中间绑定值;嵌套输出落盘抑制;通用含图片上下文延后以及 post-execute 替换/阻止优先级;64 MiB 上限内外的精确计量;日志、值与诊断的组合计量;抛出的超大堆栈;有界失败的输出落盘;不可信对端伪造的流量;以及构建后包的执行。 无密钥的真实 worker 集成测试锁定了自然语言结果无法安全支持的两种句柄工作流。后台 bash 调用返回 job id,外层运行结束,之后的运行再根据该 id 轮询直至任务完成;其他用例分别证明,预先中止不会创建任务、发布后的调用取消会保留任务、前台执行仍与信号耦合,并且由 `job_kill` 负责取消。Cordis 程序会直接读取 active 或 pending 挂载的 id 和 `waitingFor` 字段,按该 id 卸载,并在不解析渲染文本的情况下确认挂载已移除。 @@ -93,13 +93,13 @@ Code Mode 通过运行时请求中的 `{ name: "ToolCallError", memberNameProper **静默检查格式化或截断过大的完成值:**不予采纳。把 JSON 值改成字符串既有损又违反类型。显式的 `output-limit` 失败让模型可以选择返回更小的结果,而保留的日志和诊断仍可使用普通的外层 spill 机制。 -**要求每个丰富叶子工具检查 `exec.parent` 并自行延后。** 不予采用,因为这会把叶子工具与 Code Mode 内部机制耦合、重复策略处理,并遗漏未来丰富工具。分发桥接层负责从已经结算的最终结果通用转发。 +**要求每个丰富叶子工具检查 `exec.parent` 并自行延后。** 不予采用,因为这会把叶子工具与 PTC mode 内部机制耦合、重复策略处理,并遗漏未来丰富工具。分发桥接层负责从已经结算的最终结果通用转发。 **把 Native 丰富内容暴露为每个绑定规范值的一部分。** 不予采用,因为规范值是无损 JSON 且由工具定义;附件块是具有持久生命周期语义的模型投影。保持值与投影分离,既能保留类型化程序,也不会从后续模型上下文中丢弃图片。 ## 后果 -Code Mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,Code Mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 +PTC mode 程序可以通过稳定值组合工具,无需逆向解析 Native 自然语言。Native 和 Both Mode 保留现有文本与 UI 展示,PTC mode 则获得输出 schema 类型和精确的运行时 JSON。工具作者必须把规范值视为程序化 API,并将仅用于展示的格式化放入渲染器。 worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损校验,但不会降低中间值的开销,也不会使其具备持久性。外层输出溢出会显式导致运行失败,错误处理则有意由人类引导,而不是依赖带版本的错误代码联合。 @@ -110,7 +110,7 @@ worker 会以嵌套深度有界的扁平协议格式传输数据并执行无损 - 中间规范值仅存在于执行期间,无法用于回放,因为持久事件只存储展示和有界摘要。 - 中间值没有字节上限,可能因值的保留、扁平协议格式副本或结构化克隆开销而耗尽进程或 worker 内存。 - 64 MiB 硬上限只适用于外层可变负载,不计固定的结果封装语法与展示空白;spill 无法恢复超出该上限后被拒绝的字节。 -- 提供方或执行器的采集上限可能在规范值到达 Code Mode 前就已丢弃部分源数据。 +- 提供方或执行器的采集上限可能在规范值到达 PTC mode 前就已丢弃部分源数据。 - 不支持的 MCP 输出 schema 会回退为 `JsonValue`;已准入的 MCP 图片使用通用延后投影,而音频和嵌入资源载荷仍只提供诊断。 - 每个外层 `run_code` 只有一张结果卡片,嵌套调用不会各自生成卡片。 -- Code Mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。 +- PTC mode 失败只暴露 `ToolCallError` 的消息与工具名,不提供程序可用的错误代码联合。 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml index e195ff0f4b..bbdf2836d7 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-22-web-multimodal-image-input-and-durable-attachments.md -2026-07-22-web-multimodal-image-input-and-durable-attachments.md: cc94357aae24bd0ca20ee73488f14de98ffd8ca7 -2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 90667c13e2917a77ffd0bcee6386ded690573bcf +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 135939b536e39db10fe1011501b7a7ce2b0956b2 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: 3207e1f6a7fc5db65f4f4c7a51c2ae765f634cb7 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md index cc94357aae..135939b536 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -128,7 +128,7 @@ Pi-AI and the direct DeepSeek adapter resolve `ctx.attachments` at request time, Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. -Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; Code Mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. +Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP advertises image prompts only when its configured exact route and attachment deployment can accept them, persists inline input before publishing the user event, and re-reads committed assistant image references for native ACP image updates. MCP keeps canonical raw blocks for programmatic callers while projecting admitted images to durable core blocks; PTC mode carries any settled image-bearing sub-result through the outer result as logged source-attributed context. Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route uses the same deterministic request versions as ordinary turns. A text-only route receives the same deterministic attachment placeholders as any other LLM request. The synthesized checkpoint remains text-only, and `compaction-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. @@ -159,13 +159,13 @@ Malformed base64, unsupported or mismatched media, truncated image payloads, exc | `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | | `packages/acp/acp` | Conditional native image capability, atomic inline-image admission, and verified assistant-image delivery. | | `packages/mcp/mcp-client` | Lossless canonical MCP results plus capability-gated durable image projection and explicit diagnostics for unsupported rich blocks. | -| `packages/core/tools` | Generic Code Mode forwarding of settled image-bearing sub-results after the outer result. | +| `packages/core/tools` | Generic PTC mode forwarding of settled image-bearing sub-results after the outer result. | The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. ### Implementation -The implemented capability includes shared prepare-once batch admission, provider-independent masters, deterministic request versions, DeepSeek Files reuse, stable crop handles, role-neutral image blocks, Pi-AI and DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image support, lossless MCP results with durable image projection, Code Mode rich-result forwarding, bounded Web requests, draft and historical image UI, compaction handling, and keyless assembled coverage. +The implemented capability includes shared prepare-once batch admission, provider-independent masters, deterministic request versions, DeepSeek Files reuse, stable crop handles, role-neutral image blocks, Pi-AI and DeepSeek input conversion, durable Web/ACP/MCP ordering, Web upload/read protocol, conditional ACP image support, lossless MCP results with durable image projection, PTC mode rich-result forwarding, bounded Web requests, draft and historical image UI, compaction handling, and keyless assembled coverage. No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. @@ -201,11 +201,11 @@ Rejected because the core already has the role-neutral `ContentBlock` vocabulary ### Normalize MCP results into core content as the canonical tool value -Rejected because Code Mode and programmatic callers need the complete MCP JSON blocks and optional `structuredContent`; replacing that value with a Native projection would make the bridge lossy. MCP retains the protocol value and prepares a separate model projection, with final post-execute policy remaining authoritative. +Rejected because PTC mode and programmatic callers need the complete MCP JSON blocks and optional `structuredContent`; replacing that value with a Native projection would make the bridge lossy. MCP retains the protocol value and prepares a separate model projection, with final post-execute policy remaining authoritative. ### Perform attachment reads and writes inside synchronous output renderers -Rejected because tool renderers are pure, synchronous, and replayable. MCP prepares image projection during async execution and installs it only at the registry's finalization boundary; ACP performs async admission and output conversion in its transport lifecycle. Code Mode forwarding observes the already settled final content instead of giving individual image tools private parent-token behavior. +Rejected because tool renderers are pure, synchronous, and replayable. MCP prepares image projection during async execution and installs it only at the registry's finalization boundary; ACP performs async admission and output conversion in its transport lifecycle. PTC mode forwarding observes the already settled final content instead of giving individual image tools private parent-token behavior. ## Testing @@ -213,7 +213,7 @@ Rejected because tool renderers are pure, synchronous, and replayable. MCP prepa - Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection ordering, text-only queue edits, and text-only request projection. - Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.expected.e2e.ts`, `pnpm run test:web`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. - Adapter and compaction tests cover deterministic Pi-AI request versions, DeepSeek Files upload and reuse, stale-id recovery, text-only projection, recursively nested tool-result images, shared summary request versions, and explicit image-output rejection. -- Attachment, MCP, ACP, and Code Mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. +- Attachment, MCP, ACP, and PTC mode tests cover all-member validation before writes, mixed text/image ordering, no inline base64 in durable events, exact route-capability gates, explicit unsupported-content diagnostics, post-execute replacement/block precedence, cancellation during admission, verified assistant-image delivery, and generic nested-image forwarding. A keyless assembled ACP snapshot sends a real inline PNG and pins only its durable reference in the session log. - Credentialed real-API tests cover the configured Anthropic route and the built-in `deepseek-official` Files path. The DeepSeek test does not use a custom provider entry. - The current production adapter set has no certified image-output route; output-provider certification remains outside version one. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md index 90667c13e2..3207e1f6a7 100644 --- a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -128,7 +128,7 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, 核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 -提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;Code Mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 +提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。只有配置的确切路由与附件部署可以接受图片时,ACP(Agent Client Protocol)才公布图片提示词能力;它会在发布用户事件前持久化内联输入,并重新读取已提交的助手图片引用来发送原生 ACP 图片更新。MCP 为程序化调用方保留规范原始块,同时把已准入图片投影为持久核心块;PTC mode 会把任何已经结算且含图片的子结果经外层结果转运为带来源归属且写入日志的上下文。 压缩会把选定的会话前缀和其中的图片引用回放到已配置的摘要生成路径。支持视觉的路径使用与普通轮次相同的确定性请求版本。纯文本路径接收与其他 LLM 请求相同的确定性附件占位符。合成的检查点仍仅包含文本,`compaction-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 @@ -159,13 +159,13 @@ Pi-AI 与直接 DeepSeek 适配器都会在请求时解析 `ctx.attachments`, | `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | | `packages/acp/acp` | 条件式原生图片能力、原子内联图片准入,以及经过校验的助手图片交付。 | | `packages/mcp/mcp-client` | 无损规范 MCP 结果、经能力门禁的持久图片投影,以及针对不受支持丰富块的明确诊断。 | -| `packages/core/tools` | 在外层结果之后通用转发已经结算且含图片的 Code Mode 子结果。 | +| `packages/core/tools` | 在外层结果之后通用转发已经结算且含图片的 PTC mode 子结果。 | 附件包构成一个能力 seam 的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 ### 实现 -已实现能力包括只准备一次的共享批量准入、与提供方无关的主版本、确定性请求版本、DeepSeek Files 复用、稳定裁剪句柄、角色无关图片块、Pi-AI 和 DeepSeek 输入转换、Web/ACP/MCP 持久化顺序、Web 上传与读取协议、条件式 ACP 图片支持、带持久图片投影的无损 MCP 结果、Code Mode 丰富结果转发、有界 Web 请求、草稿与历史图片 UI、压缩处理,以及组装后的无密钥覆盖。 +已实现能力包括只准备一次的共享批量准入、与提供方无关的主版本、确定性请求版本、DeepSeek Files 复用、稳定裁剪句柄、角色无关图片块、Pi-AI 和 DeepSeek 输入转换、Web/ACP/MCP 持久化顺序、Web 上传与读取协议、条件式 ACP 图片支持、带持久图片投影的无损 MCP 结果、PTC mode 丰富结果转发、有界 Web 请求、草稿与历史图片 UI、压缩处理,以及组装后的无密钥覆盖。 预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 @@ -201,11 +201,11 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 ### 把 MCP 结果规范化为核心内容,并将其作为规范工具值 -不予采用,因为 Code Mode 和程序化调用方需要完整 MCP JSON 块及可选 `structuredContent`;用 Native 投影替换该值会让桥接有损。MCP 保留协议值,并另行准备模型投影;最终 post-execute 策略仍具有权威性。 +不予采用,因为 PTC mode 和程序化调用方需要完整 MCP JSON 块及可选 `structuredContent`;用 Native 投影替换该值会让桥接有损。MCP 保留协议值,并另行准备模型投影;最终 post-execute 策略仍具有权威性。 ### 在同步输出渲染器中执行附件读写 -不予采用,因为工具渲染器必须纯净、同步且可回放。MCP 在异步执行期间准备图片投影,只在注册表最终化边界安装;ACP 在自己的传输生命周期中执行异步准入和输出转换。Code Mode 转发观察已经结算的最终内容,而不是让各图片工具各自处理私有父 token 行为。 +不予采用,因为工具渲染器必须纯净、同步且可回放。MCP 在异步执行期间准备图片投影,只在注册表最终化边界安装;ACP 在自己的传输生命周期中执行异步准入和输出转换。PTC mode 转发观察已经结算的最终内容,而不是让各图片工具各自处理私有父 token 行为。 ## 测试 @@ -213,7 +213,7 @@ UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模 - 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的排序、仅文本的队列编辑,以及纯文本请求投影。 - 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.expected.e2e.ts`,`pnpm run test:web`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 - 适配器与压缩测试覆盖确定性 Pi-AI 请求版本、DeepSeek Files 上传与复用、陈旧 ID 恢复、纯文本投影、递归嵌套在工具结果中的图片、共享摘要请求版本,以及明确拒绝图片输出。 -- 附件、MCP、ACP 与 Code Mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 +- 附件、MCP、ACP 与 PTC mode 测试覆盖写入前校验全部成员、图文混合顺序、持久事件不含内联 base64、确切路由能力门禁、明确的不支持内容诊断、post-execute 替换/阻止优先级、准入期间取消、经过校验的助手图片交付,以及通用嵌套图片转发。组装后的无密钥 ACP 快照发送真实内联 PNG,并在会话日志中只固定其持久引用。 - 需要凭据的实际 API 测试会覆盖配置的 Anthropic 路由和内置 `deepseek-official` Files 路径。DeepSeek 测试不使用自定义提供方条目。 - 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md deleted file mode 100644 index a4b8deee86..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Spilling the durable copy of Code Mode sub-dispatch results - -Status: implemented - -English | [中文](2026-07-26-code-dispatch-log-spill.zh.md) - -> Scope: limiting the `tool/code-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-code-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-code-mode-live-parallel-dispatch.md) defines the event pair that this listener processes. - -## Problem - -After full-content dispatch logging was added, a `run_code` program that reads a large file wrote the complete rendered text into the session log without a limit or spill policy, while native results were limited to `maxInlineBytes` before logging. This treated the most likely large results differently: sub-calls are intended for bulk data work, and each affected turn added megabytes to the JSONL. - -## Decision - -**A `tools/code-dispatch-log` waterfall on the registry, with spill policy as its first listener.** - -- **Extension point**: `tools/code-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/code-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `CodeDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. -- **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. -- **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. - -## Alternatives considered - -**Apply a plain byte limit inside the bridge without spill storage.** Rejected: truncation without a locator loses data that replay or UIs may need and restores the less informative "truncated summary" rendering that earlier changes removed. - -**Spill inside the bridge directly by calling `ctx.spillStore` from `code-mode.ts`.** Rejected: the registry would require the spill capability. The waterfall keeps this policy with the other spill decisions and allows compositions to omit it; omitting `maxInlineBytes` still makes the listener a no-op. - -**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute can change the program-facing result, so nested calls deliberately skip it and programs receive complete data. The durable copy needs a separate listener that runs after the program has its value. - -## Consequences - -Code Mode dispatch entries in the session log now have the configured byte limit, and the README's Known Limitations entry about unlimited dispatch logging now points here. Old logs with oversized dispatch content still replay because the event fields are unchanged; only future appends contain less text. The web UI renders spilled sub-call output as preview and locator text through the same path as native results, with no special case. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md deleted file mode 100644 index 5e53d76136..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: 将 Code Mode 子分发结果的持久化副本纳入 spill 机制 - -Status: implemented - -[English](2026-07-26-code-dispatch-log-spill.md) | 中文 - -> 范围:用既有的 spill 实现限制 `tool/code-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-code-dispatch-ui-foundation.zh.md) 有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-code-mode-live-parallel-dispatch.zh.md) 定义了该监听器处理的事件对。 - -## 问题 - -加入完整内容的分发日志后,读取大文件的 `run_code` 程序会把完整的渲染文本写进会话日志,既没有上限,也不经过 spill 策略;原生结果则会在记录之前限制在 `maxInlineBytes` 以内。两类结果受到不同处理,而为批量数据工作设计的子调用最可能产生巨大结果;每个受影响的轮次都会让 JSONL 增长数 MB。 - -## 决策 - -**在注册表上增设 `tools/code-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** - -- **扩展点**:`tools/code-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/code-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 以能力闭包形式接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`CodeDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会等待开放轮次内的全部任务完成。 -- **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过被 spill 的原生结果所用的同一路径读取全文,因此两类结果会渲染出相同的信息。 -- **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 - -## 曾考虑的替代方案 - -**在桥接层内部使用普通字节数上限,不存入 spill。** 否决:没有定位符的截断会丢失回放或 UI 可能需要的数据,还会恢复之前更改已经移除的、信息较少的「截断摘要」渲染。 - -**直接在桥接层内做 spill,即从 `code-mode.ts` 调用 `ctx.spillStore`。** 否决:注册表会要求提供 spill 能力。waterfall 把该策略与其他 spill 决策放在一起,并允许组合不加载它;省略 `maxInlineBytes` 时,该监听器仍不执行任何操作。 - -**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 可以修改面向程序的结果,因此嵌套调用有意跳过它,让程序取得完整数据。持久化副本需要一个单独的监听器,在程序取得其值之后运行。 - -## 后果 - -会话日志中的 Code Mode 分发条目现在遵守已配置的字节数上限,README 中关于分发日志不设上限的「已知限制」条目现在指向本篇。携带超大分发内容的旧日志仍可回放,因为事件字段没有变化;只有今后的追加包含更少文本。Web UI 经由与原生结果相同的路径,把被 spill 的子调用输出渲染为预览和定位符文本,不需要特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml deleted file mode 100644 index bc71339309..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/feature/2026-07-26-code-mode-chat-subcall-rows.md -2026-07-26-code-mode-chat-subcall-rows.md: 4bf608c36de78818b3ab40c6798ee6eafabe99a1 -2026-07-26-code-mode-chat-subcall-rows.zh.md: 6e11f65313fb35dc202cb2cd230884b8862d9f13 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml deleted file mode 100644 index 7478d79c43..0000000000 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/feature/2026-07-26-code-mode-live-parallel-dispatch.md -2026-07-26-code-mode-live-parallel-dispatch.md: 998152ee43d93e5190062cb632e20a3123cda4f9 -2026-07-26-code-mode-live-parallel-dispatch.zh.md: e70e23a326e645749fe961d1d55a47fc5753ee75 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml new file mode 100644 index 0000000000..5b72a7564f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.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/feature/2026-07-26-ptc-chat-subcall-rows.md +2026-07-26-ptc-chat-subcall-rows.md: ab0d796e5c44d4edefaf7be709c272a756043107 +2026-07-26-ptc-chat-subcall-rows.zh.md: c4dfc72a8f29de8c0eaf6b80e0788a6773df3d11 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md similarity index 53% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md index 4bf608c36d..ab0d796e5c 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md @@ -1,20 +1,20 @@ -# Agent Note: Code Mode chat rendering — sub-calls as native rows under the parent +# Agent Note: PTC mode chat rendering — sub-calls as native rows under the parent Status: implemented -English | [中文](2026-07-26-code-mode-chat-subcall-rows.zh.md) +English | [中文](2026-07-26-ptc-chat-subcall-rows.zh.md) -> Scope: how the web chat view renders a `run_code` turn — the client-side half of the Code Mode UI stack, built on the [host foundation](2026-07-26-code-dispatch-ui-foundation.md) (full-content `tool/code-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. +> Scope: how the web chat view renders a `run_code` turn — the client-side half of the PTC mode UI stack, built on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) (full-content `tool/ptc-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. ## Problem -With Code Mode enabled, the chat view showed one opaque `run_code` row: raw program text as the summary, sub-calls invisible everywhere. The settled product requirement is the opposite: each sub-call must render *identically* to a native tool call — same row components, same custom registrations, same details panel — while the transcript stays honest about the fact that the model made ONE call. +With PTC mode enabled, the chat view showed one opaque `run_code` row: raw program text as the summary, sub-calls invisible everywhere. The settled product requirement is the opposite: each sub-call must render *identically* to a native tool call — same row components, same custom registrations, same details panel — while the transcript stays honest about the fact that the model made ONE call. ## Decision **Sub-calls are standard Tool call blocks attached recursively to their parent outside the surface flow, rendered through the same keyed slot as native rows, and always visible under their parent.** -- **Data layer**: Runtime's `ToolCallTree` folds in-window `tool/code-dispatch-start` and `tool/code-dispatch` events into a private per-parent index, then projects running and settled children onto recursive `ToolCallBlock.subCalls`. Live Session projection and `projectConversationHistory` share that fold; copy-on-write parent arrays and path-copy projection keep unrelated roots and siblings reference-stable. Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The events are narrowed structurally at the wire-consumer boundary, which also rejects cyclic parent relationships (dsh-tools' host types cannot enter the client program because the host/client `Context` merges collide). +- **Data layer**: Runtime's `ToolCallTree` folds in-window `tool/ptc-dispatch-start` and `tool/ptc-dispatch` events into a private per-parent index, then projects running and settled children onto recursive `ToolCallBlock.subCalls`. Live Session projection and `projectConversationHistory` share that fold; copy-on-write parent arrays and path-copy projection keep unrelated roots and siblings reference-stable. Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The events are narrowed structurally at the wire-consumer boundary, which also rejects cyclic parent relationships (dsh-tools' host types cannot enter the client program because the host/client `Context` merges collide). - **Render layer**: `ChatView` passes each parent with its recursive children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by `[data-subcalls]` nests, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over descendant and top-level calls without registration changes. Running parents (`runningCalls`) receive accumulated dispatches in the same recursive block, so child rows stream in during the run. - **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope. - **Details panel**: `materialFor` recursively searches `nodes` and `runningCalls`, so a selected descendant callId resolves to full args and complete output through the identical rendering path as a native settled call. @@ -23,10 +23,10 @@ With Code Mode enabled, the chat view showed one opaque `run_code` row: raw prog **Sub-calls flat in the surface flow (fold them into `nodes`).** Rejected: misrepresents the transcript — the model made one call; nesting under the parent preserves the code↔calls association and keeps the fold's model-visible-order invariant untouched. -**Hidden until the parent row expands.** Rejected by product decision: the sub-calls ARE the story of a Code Mode turn; hiding them re-creates the opacity this feature removes. The parent's expand toggle reveals only the program. +**Hidden until the parent row expands.** Rejected by product decision: the sub-calls ARE the story of a PTC mode turn; hiding them re-creates the opacity this feature removes. The parent's expand toggle reveals only the program. **A dedicated sub-call row component.** Rejected: the whole point is identity with native rows; a parallel component would drift. The nest wrapper (indent + left edge) is the only sub-call-specific chrome. ## Consequences -Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership searches the whole tree). Trajectory/waterfall now draw sub-call spans from the dispatch timing pair ([live parallel dispatch](2026-07-26-code-mode-live-parallel-dispatch.md)); without that timing a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `code-mode-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom and Runtime suites pin slot dispatch, error states, recursive details resolution, history projection, and reference-stable path copying. +Custom toolview registrations apply to sub-calls for free — and deliberately: there is no per-registration opt-out short of the component reading its own context, which no current consumer needs. Selection highlighting reaches nested rows through the same `selectedCallId` channel (group membership searches the whole tree). Trajectory/waterfall now draw sub-call spans from the dispatch timing pair ([live parallel dispatch](2026-07-26-ptc-live-parallel-dispatch.md)); without that timing a waterfall span would be a lie. Fixture turn 64 (`?fixture`) plus the `ptc-round` browser e2e (recorded real round, keyless replay) pin the full surface; the jsdom and Runtime suites pin slot dispatch, error states, recursive details resolution, history projection, and reference-stable path copying. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md similarity index 51% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md index 6e11f65313..c4dfc72a8f 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-chat-subcall-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md @@ -1,20 +1,20 @@ -# Agent Note: Code Mode 的 chat 渲染——子调用作为父行之下的原生行 +# Agent Note: PTC mode 的 chat 渲染——子调用作为父行之下的原生行 Status: implemented -[English](2026-07-26-code-mode-chat-subcall-rows.md) | 中文 +[English](2026-07-26-ptc-chat-subcall-rows.md) | 中文 -> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 Code Mode UI 栈的客户端侧部分,构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.zh.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.zh.md)所有。 +> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 PTC mode UI 栈的客户端侧部分,构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)之上(携带完整内容的 `tool/ptc-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.zh.md)所有。 ## 问题 -启用 Code Mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的详情面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 +启用 PTC mode 后,chat 视图过去只显示一条不透明的 `run_code` 行:摘要就是原始程序文本,子调用则处处不可见。已敲定的产品要求恰恰相反:每个子调用都必须与原生工具调用渲染得*完全一致*——同样的行组件、同样的自定义注册、同样的详情面板——同时 transcript(文本记录)仍须如实反映模型只发起了一次调用这一事实。 ## 决策 **子调用是在 surface 流之外递归附着到父级的标准工具调用块,经由与原生行相同的 keyed slot 渲染,并始终显示在父级之下。** -- **数据层**:运行时的 `ToolCallTree` 把窗口内的 `tool/code-dispatch-start` 与 `tool/code-dispatch` 事件折入私有的逐父级索引,再把运行中和已结算的子级投影到递归的 `ToolCallBlock.subCalls` 上。实时会话投影与 `projectConversationHistory` 共享这一折叠过程;逐父级的写时复制数组和路径复制投影让无关根节点与兄弟节点保持引用稳定。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。这些事件在 wire 消费方边界作结构性收窄,该边界也会拒绝成环的父子关系(dsh-tools 的宿主类型无法进入客户端程序,因为宿主端与客户端两侧的 `Context` 声明合并会冲突)。 +- **数据层**:运行时的 `ToolCallTree` 把窗口内的 `tool/ptc-dispatch-start` 与 `tool/ptc-dispatch` 事件折入私有的逐父级索引,再把运行中和已结算的子级投影到递归的 `ToolCallBlock.subCalls` 上。实时会话投影与 `projectConversationHistory` 共享这一折叠过程;逐父级的写时复制数组和路径复制投影让无关根节点与兄弟节点保持引用稳定。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。这些事件在 wire 消费方边界作结构性收窄,该边界也会拒绝成环的父子关系(dsh-tools 的宿主类型无法进入客户端程序,因为宿主端与客户端两侧的 `Context` 声明合并会冲突)。 - **渲染层**:`ChatView` 通过整体工具 seat `'conversation.chat.tool'` 传递每个父调用及其递归子调用。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以工具名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管任意后代与顶层调用。运行中的 parent(`runningCalls`)在同一个递归块中接收已累积的 dispatch,使 child 行在运行期间实时流入。 - **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。 - **详情面板**:`materialFor` 递归搜索 `nodes` 与 `runningCalls`,因此被选中的后代 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 @@ -23,10 +23,10 @@ Status: implemented **把子调用平铺进 surface 流(折入 `nodes`)。** 否决:这会歪曲 transcript——模型只发起了一次调用;嵌套在父行之下既保住代码↔调用的关联,也让折叠过程的模型可见顺序不变式原封不动。 -**隐藏子调用,展开父行后才显示。** 由产品决策否决:子调用正是一个 Code Mode 轮次的核心内容;把它们藏起来,等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。 +**隐藏子调用,展开父行后才显示。** 由产品决策否决:子调用正是一个 PTC mode 轮次的核心内容;把它们藏起来,等于重新制造出本功能所要消除的那种不透明。父行的展开开关只用于显示程序本身。 **专用的子调用行组件。** 否决:本功能的全部要义就在于与原生行保持同一性;一个平行组件必然漂移。嵌套包装层(缩进 + 左侧边线)是子调用唯一的专属视觉装饰。 ## 后果 -自定义 toolview 注册无需额外改动即可适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属会搜索整棵树)。trajectory/waterfall 现在依据分发计时事件对([实时并行分发](2026-07-26-code-mode-live-parallel-dispatch.zh.md))绘制子调用 span;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `code-mode-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 与运行时测试套件则锁定 slot 分发、错误状态、递归详情解析、历史投影与引用稳定的路径复制。 +自定义 toolview 注册无需额外改动即可适用于子调用——而且是刻意为之:不存在按注册粒度的退出机制,唯一的出路是组件自行读取自身上下文,而当前没有任何消费方需要这么做。选中高亮经由同一条 `selectedCallId` 通道到达嵌套行(分组归属会搜索整棵树)。trajectory/waterfall 现在依据分发计时事件对([实时并行分发](2026-07-26-ptc-live-parallel-dispatch.zh.md))绘制子调用 span;缺少计时,waterfall 上的 span 就是在撒谎。fixture(测试前置数据)的轮次 64(`?fixture`),加上 `ptc-round` 浏览器 e2e(录制的真实轮次、无密钥回放),共同锁定整个界面;jsdom 与运行时测试套件则锁定 slot 分发、错误状态、递归详情解析、历史投影与引用稳定的路径复制。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml new file mode 100644 index 0000000000..385bfcfaaf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.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/feature/2026-07-26-ptc-dispatch-log-spill.md +2026-07-26-ptc-dispatch-log-spill.md: ca88e751bada63229f658907812742175f523137 +2026-07-26-ptc-dispatch-log-spill.zh.md: b115e746c01b5c22cee42ed14e6bdb1c4409694c diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md new file mode 100644 index 0000000000..ca88e751ba --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md @@ -0,0 +1,31 @@ +# Agent Note: Spilling the durable copy of PTC mode sub-dispatch results + +Status: implemented + +English | [中文](2026-07-26-ptc-dispatch-log-spill.zh.md) + +> Scope: limiting the `tool/ptc-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-ptc-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) defines the event pair that this listener processes. + +## Problem + +After full-content dispatch logging was added, a `run_code` program that reads a large file wrote the complete rendered text into the session log without a limit or spill policy, while native results were limited to `maxInlineBytes` before logging. This treated the most likely large results differently: sub-calls are intended for bulk data work, and each affected turn added megabytes to the JSONL. + +## Decision + +**A `tools/ptc-dispatch-log` waterfall on the registry, with spill policy as its first listener.** + +- **Extension point**: `tools/ptc-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/ptc-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `PtcDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. +- **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. +- **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. + +## Alternatives considered + +**Apply a plain byte limit inside the bridge without spill storage.** Rejected: truncation without a locator loses data that replay or UIs may need and restores the less informative "truncated summary" rendering that earlier changes removed. + +**Spill inside the bridge directly by calling `ctx.spillStore` from `ptc.ts`.** Rejected: the registry would require the spill capability. The waterfall keeps this policy with the other spill decisions and allows compositions to omit it; omitting `maxInlineBytes` still makes the listener a no-op. + +**Reuse `tools/post-execute` for nested calls instead of a new event.** Rejected: post-execute can change the program-facing result, so nested calls deliberately skip it and programs receive complete data. The durable copy needs a separate listener that runs after the program has its value. + +## Consequences + +PTC mode dispatch entries in the session log now have the configured byte limit, and the README's Known Limitations entry about unlimited dispatch logging now points here. Old logs with oversized dispatch content still replay because the event fields are unchanged; only future appends contain less text. The web UI renders spilled sub-call output as preview and locator text through the same path as native results, with no special case. diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md new file mode 100644 index 0000000000..b115e746c0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 将 PTC mode 子分发结果的持久化副本纳入 spill 机制 + +Status: implemented + +[English](2026-07-26-ptc-dispatch-log-spill.md) | 中文 + +> 范围:用既有的 spill 实现限制 `tool/ptc-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-ptc-dispatch-ui-foundation.zh.md) 有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 定义了该监听器处理的事件对。 + +## 问题 + +加入完整内容的分发日志后,读取大文件的 `run_code` 程序会把完整的渲染文本写进会话日志,既没有上限,也不经过 spill 策略;原生结果则会在记录之前限制在 `maxInlineBytes` 以内。两类结果受到不同处理,而为批量数据工作设计的子调用最可能产生巨大结果;每个受影响的轮次都会让 JSONL 增长数 MB。 + +## 决策 + +**在注册表上增设 `tools/ptc-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** + +- **扩展点**:`tools/ptc-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/ptc-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 以能力闭包形式接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`PtcDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会等待开放轮次内的全部任务完成。 +- **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过被 spill 的原生结果所用的同一路径读取全文,因此两类结果会渲染出相同的信息。 +- **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 + +## 曾考虑的替代方案 + +**在桥接层内部使用普通字节数上限,不存入 spill。** 否决:没有定位符的截断会丢失回放或 UI 可能需要的数据,还会恢复之前更改已经移除的、信息较少的「截断摘要」渲染。 + +**直接在桥接层内做 spill,即从 `ptc.ts` 调用 `ctx.spillStore`。** 否决:注册表会要求提供 spill 能力。waterfall 把该策略与其他 spill 决策放在一起,并允许组合不加载它;省略 `maxInlineBytes` 时,该监听器仍不执行任何操作。 + +**让嵌套调用复用 `tools/post-execute`,而不是新增一个事件。** 否决:post-execute 可以修改面向程序的结果,因此嵌套调用有意跳过它,让程序取得完整数据。持久化副本需要一个单独的监听器,在程序取得其值之后运行。 + +## 后果 + +会话日志中的 PTC mode 分发条目现在遵守已配置的字节数上限,README 中关于分发日志不设上限的「已知限制」条目现在指向本篇。携带超大分发内容的旧日志仍可回放,因为事件字段没有变化;只有今后的追加包含更少文本。Web UI 经由与原生结果相同的路径,把被 spill 的子调用输出渲染为预览和定位符文本,不需要特殊处理。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml new file mode 100644 index 0000000000..deef5e0a47 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.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/feature/2026-07-26-ptc-dispatch-ui-foundation.md +2026-07-26-ptc-dispatch-ui-foundation.md: d91f914a564654a7e3925caf367478b282ddbdc4 +2026-07-26-ptc-dispatch-ui-foundation.zh.md: 8a26f0a1d4ccd4937b856b1c8f3a8be38d8c4174 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md similarity index 55% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md index 2b919dfb97..d91f914a56 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md @@ -1,26 +1,26 @@ -# Agent Note: Code Mode UI foundation — run_code description and native-parity dispatch logging +# Agent Note: PTC mode UI foundation — run_code description and native-parity dispatch logging Status: implemented -English | [中文](2026-07-26-code-dispatch-ui-foundation.zh.md) +English | [中文](2026-07-26-ptc-dispatch-ui-foundation.zh.md) -> Scope: the host-side contract changes that let a UI render a Code Mode turn with the same fidelity as native tool calls — the foundation the other Code Mode UI notes build on. The [Code Mode foundation](2026-06-15-code-mode.md) owns the transport design; this note owns the model-visible `description` parameter, the full-content `tool/code-dispatch` payload, and the temporary `DSH_TOOLS_MODE` enablement switch for the `dsh` config tree. +> Scope: the host-side contract changes that let a UI render a PTC mode turn with the same fidelity as native tool calls — the foundation the other PTC mode UI notes build on. The [PTC mode foundation](2026-06-15-ptc.md) owns the transport design; this note owns the model-visible `description` parameter, the full-content `tool/ptc-dispatch` payload, and the temporary `DSH_TOOLS_MODE` enablement switch for the `dsh` config tree. ## Problem -A `run_code` turn was opaque in every product surface. The call card's title was the raw program text — unreadable at row width, and unlike `bash` (whose required `description` labels the card while the command rides the expanded input) there was no model-authored label at all. The `tool/code-dispatch` event carried only a 200-char, cwd-normalized `resultSummary` of each sub-call, so no UI could ever show what a sub-call actually returned: the web conversation view ([chat sub-call rows](2026-07-26-code-mode-chat-subcall-rows.md)) renders sub-calls through the exact components that render native `tool/result` cards, and a bounded summary cannot feed a native-parity card. And the `dsh web` composition had no way to enable Code Mode at all — the `tools` row pinned the schema default and the runtime was absent from the tree. +A `run_code` turn was opaque in every product surface. The call card's title was the raw program text — unreadable at row width, and unlike `bash` (whose required `description` labels the card while the command rides the expanded input) there was no model-authored label at all. The `tool/ptc-dispatch` event carried only a 200-char, cwd-normalized `resultSummary` of each sub-call, so no UI could ever show what a sub-call actually returned: the web conversation view ([chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md)) renders sub-calls through the exact components that render native `tool/result` cards, and a bounded summary cannot feed a native-parity card. And the `dsh web` composition had no way to enable PTC mode at all — the `tools` row pinned the schema default and the runtime was absent from the tree. ## Decision 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. +2. **`tool/ptc-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 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-presentation selection owned by the web UI is the design goal, and the env var dies when that lands. ## Alternatives considered -**Keep a bounded summary (raised cap, or a cap + `truncated` flag).** Rejected: the stack's settled requirement is that sub-call rows and details render *identically* to native calls; any cap forces a second, degraded render path plus truncation UI. The cost accepted instead: a program that reads a large file logs the rendered content verbatim on the dispatch event — uncapped, outside spill policy, growing the session log by the same bytes. Spill integration for the logged copy shipped as [code-dispatch log spill](2026-07-26-code-dispatch-log-spill.md). +**Keep a bounded summary (raised cap, or a cap + `truncated` flag).** Rejected: the stack's settled requirement is that sub-call rows and details render *identically* to native calls; any cap forces a second, degraded render path plus truncation UI. The cost accepted instead: a program that reads a large file logs the rendered content verbatim on the dispatch event — uncapped, outside spill policy, growing the session log by the same bytes. Spill integration for the logged copy shipped as [ptc-dispatch log spill](2026-07-26-ptc-dispatch-log-spill.md). **A `--tools-mode` CLI flag or profile key.** Deferred, not rejected: the flag grammar suggests permanence, and the profile json is user config — both would harden a seam the per-session design intends to remove. An env var reads as the workaround it is. @@ -28,4 +28,4 @@ Three changes, one per obstacle: ## Consequences -Session format keeps `SESSION_FORMAT_VERSION` 0 (pre-release churn does not bump; old logs with `resultSummary` simply carry an extra unread field and lack `content` — v0 makes no compatibility promise). Existing code-mode snapshot fixtures were re-recorded. Model-visible surface grew: the `run_code` schema (one required parameter) and every code-mode system prompt/tool-schema snapshot changed. The web UI work builds directly on the new event payload; live per-sub-call running state reshaped this event into a dispatch start/end pair ([live parallel dispatch](2026-07-26-code-mode-live-parallel-dispatch.md)). +Session format keeps `SESSION_FORMAT_VERSION` 0 (pre-release churn does not bump; old logs with `resultSummary` simply carry an extra unread field and lack `content` — v0 makes no compatibility promise). Existing ptc snapshot fixtures were re-recorded. Model-visible surface grew: the `run_code` schema (one required parameter) and every ptc system prompt/tool-schema snapshot changed. The web UI work builds directly on the new event payload; live per-sub-call running state reshaped this event into a dispatch start/end pair ([live parallel dispatch](2026-07-26-ptc-live-parallel-dispatch.md)). diff --git a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md similarity index 57% rename from .agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md index 33599c5689..8a26f0a1d4 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md @@ -1,26 +1,26 @@ -# Agent Note: Code Mode 的 UI 基础——run_code 的 description 参数,以及与原生同等保真的分发日志 +# Agent Note: PTC mode 的 UI 基础——run_code 的 description 参数,以及与原生同等保真的分发日志 Status: implemented -[English](2026-07-26-code-dispatch-ui-foundation.md) | 中文 +[English](2026-07-26-ptc-dispatch-ui-foundation.md) | 中文 -> 范围:让 UI 能以与原生工具调用相同的保真度渲染 Code Mode 轮次的宿主侧约定变更,即其他 Code Mode UI Agent Note 赖以构建的基础。传输设计归 [Code Mode 基础](2026-06-15-code-mode.zh.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/code-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用开关,归本篇所有。 +> 范围:让 UI 能以与原生工具调用相同的保真度渲染 PTC mode 轮次的宿主侧约定变更,即其他 PTC mode UI Agent Note 赖以构建的基础。传输设计归 [PTC mode 基础](2026-06-15-ptc.zh.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/ptc-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用开关,归本篇所有。 ## 问题 -`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/code-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:Web 对话视图([chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.zh.md))会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 Code Mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 +`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/ptc-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:Web 对话视图([chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md))会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 PTC mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 ## 决策 三项变更,每项对应一个障碍: 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()` 忽略它):模型上下文没有任何变化。 +2. **`tool/ptc-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件仍仅用于日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`code`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 -**保留有界摘要(提高上限,或上限加 `truncated` 标志)。** 否决:本堆叠 PR(Pull Request)链已敲定的要求是,子调用的行与详情必须与原生调用渲染得*完全一致*;任何上限都会强制引入第二条降级的渲染路径,外加截断 UI。转而接受的代价是:读取大文件的程序会把渲染后的内容原样记录在分发事件上,不设上限、位于 spill 策略之外,并以同样的字节数增大会话日志。持久化副本的 spill 集成已作为 [code-dispatch 日志 spill](2026-07-26-code-dispatch-log-spill.zh.md) 交付。 +**保留有界摘要(提高上限,或上限加 `truncated` 标志)。** 否决:本堆叠 PR(Pull Request)链已敲定的要求是,子调用的行与详情必须与原生调用渲染得*完全一致*;任何上限都会强制引入第二条降级的渲染路径,外加截断 UI。转而接受的代价是:读取大文件的程序会把渲染后的内容原样记录在分发事件上,不设上限、位于 spill 策略之外,并以同样的字节数增大会话日志。持久化副本的 spill 集成已作为 [ptc-dispatch 日志 spill](2026-07-26-ptc-dispatch-log-spill.zh.md) 交付。 **一个 `--tools-mode` CLI(命令行界面)标志或 profile 配置键。** 推迟,而非否决:标志语法暗示永久性,profile JSON 又是用户配置;两者都会固化这个 seam,而按会话选择的设计本就打算移除它。环境变量则如实呈现了它权宜之计的本质。 @@ -28,4 +28,4 @@ Status: implemented ## 后果 -会话格式保持 `SESSION_FORMAT_VERSION` 为 0(预发布阶段的变动不递增版本号;携带 `resultSummary` 的旧日志只是多出一个不被读取的字段并缺少 `content`;v0 不作任何兼容性承诺)。既有的 Code Mode 快照 fixture(测试前置数据)已重新录制。模型可见范围扩大了:`run_code` 的 schema(新增一个必填参数)以及每一份 Code Mode 系统提示词/工具 schema 快照都发生了变化。Web UI 工作直接构建在新的事件载荷之上;每个子调用的实时运行状态已把本事件重塑为一对分发 start/end 事件([实时并行分发](2026-07-26-code-mode-live-parallel-dispatch.zh.md))。 +会话格式保持 `SESSION_FORMAT_VERSION` 为 0(预发布阶段的变动不递增版本号;携带 `resultSummary` 的旧日志只是多出一个不被读取的字段并缺少 `content`;v0 不作任何兼容性承诺)。既有的 PTC mode 快照 fixture(测试前置数据)已重新录制。模型可见范围扩大了:`run_code` 的 schema(新增一个必填参数)以及每一份 PTC mode 系统提示词/工具 schema 快照都发生了变化。Web UI 工作直接构建在新的事件载荷之上;每个子调用的实时运行状态已把本事件重塑为一对分发 start/end 事件([实时并行分发](2026-07-26-ptc-live-parallel-dispatch.zh.md))。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml new file mode 100644 index 0000000000..0ed68d66a7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.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/feature/2026-07-26-ptc-live-parallel-dispatch.md +2026-07-26-ptc-live-parallel-dispatch.md: 647142d8bc23de211f2a788468c13c45f2139104 +2026-07-26-ptc-live-parallel-dispatch.zh.md: 7e66cdfa9aec020cd463df984da3ab20f83214e9 diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md similarity index 73% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md index 998152ee43..647142d8bc 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md @@ -1,27 +1,27 @@ -# Agent Note: Code Mode live dispatch lifecycle and native-contract parallelism +# Agent Note: PTC mode live dispatch lifecycle and native-contract parallelism Status: implemented -English | [中文](2026-07-26-code-mode-live-parallel-dispatch.zh.md) +English | [中文](2026-07-26-ptc-live-parallel-dispatch.zh.md) -> Scope: the `tool/code-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-code-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-code-mode-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). +> Scope: the `tool/ptc-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). ## Problem -Two gaps remained after the host foundation and chat sub-call rows shipped. Sub-call rows appeared only when each dispatch *settled* — while one ran, the UI showed nothing for it, so a slow sub-call read as a stalled parent. And the bridge serialized every binding call ("even `Promise.all` executes one at a time"), a placeholder from before tools carried concurrency metadata: `isConcurrencySafe` now exists, the loop scheduler already runs native siblings in bounded pools, and a Code Mode program awaiting three independent reads paid 3× the latency the native path would. +Two gaps remained after the host foundation and chat sub-call rows shipped. Sub-call rows appeared only when each dispatch *settled* — while one ran, the UI showed nothing for it, so a slow sub-call read as a stalled parent. And the bridge serialized every binding call ("even `Promise.all` executes one at a time"), a placeholder from before tools carried concurrency metadata: `isConcurrencySafe` now exists, the loop scheduler already runs native siblings in bounded pools, and a PTC mode program awaiting three independent reads paid 3× the latency the native path would. ## Decision **One lifecycle pair, one scheduling contract, shared with native.** -- **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0. +- **Event pair**: `tool/ptc-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/ptc-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0. - **Bridge scheduler**: submitted calls are classified at start time via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, `prepare` (pre-execute/guards), the head-of-line `finalize`/`finish` commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (`fillPool` awaits `startCall` then `commitReady`). Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a `Config` field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn. - **Client**: Runtime's `ToolCallTree` stores a start event as a `RunningToolCall` child and projects it through the parent's recursive `subCalls` (rows derive the running ring from that shape, exactly as for native in-flight calls). Its settle replaces the private-index entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering. -- **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every code-mode snapshot. +- **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every ptc snapshot. ## Alternatives considered -**Unrestricted parallelism (let `Promise.all` overlap everything).** Rejected: writes could race; the native scheduler exists precisely because the tool, not the caller, owns the safety claim. One concurrency vocabulary across native and Code Mode was the settled requirement. +**Unrestricted parallelism (let `Promise.all` overlap everything).** Rejected: writes could race; the native scheduler exists precisely because the tool, not the caller, owns the safety claim. One concurrency vocabulary across native and PTC mode was the settled requirement. **Emit the start event at submission instead of pool entry.** Rejected: a submission-time start would show queued-but-never-run calls as "running" and would force a third "abandoned" terminal event to reconcile the log. Start-at-entry keeps the invariant *started ⇔ settles exactly once* and needs no third event. @@ -29,4 +29,4 @@ Two gaps remained after the host foundation and chat sub-call rows shipped. Sub- ## Consequences -Programs get native-grade latency for independent reads with no new model-side API — `Promise.all` simply works better, and prompt guidance changed accordingly. The web UI shows per-sub-call running rings live (fixture emits start/settle pairs; jsdom pins the running shape; the runtime spec pins in-place settlement, out-of-order completion, and callTime pairing). Trajectory/waterfall sub-call spans draw truthful timing from the pair. Spill bounding ([code-dispatch log spill](2026-07-26-code-dispatch-log-spill.md)) inherits the settle event as its single bounding point. +Programs get native-grade latency for independent reads with no new model-side API — `Promise.all` simply works better, and prompt guidance changed accordingly. The web UI shows per-sub-call running rings live (fixture emits start/settle pairs; jsdom pins the running shape; the runtime spec pins in-place settlement, out-of-order completion, and callTime pairing). Trajectory/waterfall sub-call spans draw truthful timing from the pair. Spill bounding ([ptc-dispatch log spill](2026-07-26-ptc-dispatch-log-spill.md)) inherits the settle event as its single bounding point. diff --git a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md similarity index 73% rename from .agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md rename to .agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md index e70e23a326..7e66cdfa9a 100644 --- a/.agents/notes/implemented/feature/2026-07-26-code-mode-live-parallel-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md @@ -1,27 +1,27 @@ -# Agent Note: Code Mode 的实时分发生命周期,以及复用原生约定的并行执行 +# Agent Note: PTC mode 的实时分发生命周期,以及复用原生约定的并行执行 Status: implemented -[English](2026-07-26-code-mode-live-parallel-dispatch.md) | 中文 +[English](2026-07-26-ptc-live-parallel-dispatch.md) | 中文 -> 范围:`tool/code-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](2026-07-26-code-dispatch-ui-foundation.zh.md)与 [chat 子调用行](2026-07-26-code-mode-chat-subcall-rows.zh.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 +> 范围:`tool/ptc-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)与 [chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 ## 问题 -宿主侧基础与 chat 子调用行交付之后仍留有两个缺口。子调用行过去只在每次分发*结算*后才出现:某次分发运行期间,UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在,agent loop(智能体循环)调度器早已在有界并发池中运行原生兄弟调用,而一个等待三个独立读取的 Code Mode 程序,付出的延迟却是原生路径的 3 倍。 +宿主侧基础与 chat 子调用行交付之后仍留有两个缺口。子调用行过去只在每次分发*结算*后才出现:某次分发运行期间,UI 对它毫无展示,于是一个慢的子调用看上去就像父调用卡住了。而桥接层过去把每一次绑定调用都串行化(「即使 `Promise.all` 也一次只执行一个」),这是工具尚未携带并发元数据时留下的占位实现:如今 `isConcurrencySafe` 已经存在,agent loop(智能体循环)调度器早已在有界并发池中运行原生兄弟调用,而一个等待三个独立读取的 PTC mode 程序,付出的延迟却是原生路径的 3 倍。 ## 决策 **一对生命周期事件,一份调度约定,与原生共用。** -- **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响;格式保持 v0。 +- **事件对**:`tool/ptc-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/ptc-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响;格式保持 v0。 - **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 约定),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。 - **客户端侧**:运行时的 `ToolCallTree` 把 start 事件存为 `RunningToolCall` 子级,并通过父级递归的 `subCalls` 投影出来(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致)。其结算事件会原位替换私有索引中的条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。 -- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实约定(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 Code Mode 快照都已重新录制。 +- **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实约定(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 PTC mode 快照都已重新录制。 ## 曾考虑的替代方案 -**不加限制的并行(让 `Promise.all` 重叠一切)。** 否决:写操作可能产生竞态;原生调度器之所以存在,正是因为安全性声明归工具所有,而不归调用方。原生与 Code Mode 使用同一套并发词汇,是已敲定的要求。 +**不加限制的并行(让 `Promise.all` 重叠一切)。** 否决:写操作可能产生竞态;原生调度器之所以存在,正是因为安全性声明归工具所有,而不归调用方。原生与 PTC mode 使用同一套并发词汇,是已敲定的要求。 **在提交时而非入池时发出 start 事件。** 否决:提交即发 start 会把排了队却从未运行的调用显示成「运行中」,还得强行引入第三种「已放弃」终态事件才能使日志自洽。入池才发 start 保住了*已启动 ⇔ 恰好结算一次*这一不变式,且不需要第三种事件。 @@ -29,4 +29,4 @@ Status: implemented ## 后果 -程序不需要任何新的模型侧 API,独立读取就获得了原生级的延迟:`Promise.all` 直接变得更好用,提示词指引也随之修改。Web UI 实时显示每个子调用的运行指示环:fixture(测试前置数据)发出成对的 start/settle 事件;jsdom 锁定运行中形状;运行时测试锁定原位结算、乱序完成与 callTime 配对。trajectory/waterfall 的子调用 span 从这对事件取得如实的计时。spill 边界划定([code-dispatch 日志 spill](2026-07-26-code-dispatch-log-spill.zh.md))则以结算事件作为唯一的边界点。 +程序不需要任何新的模型侧 API,独立读取就获得了原生级的延迟:`Promise.all` 直接变得更好用,提示词指引也随之修改。Web UI 实时显示每个子调用的运行指示环:fixture(测试前置数据)发出成对的 start/settle 事件;jsdom 锁定运行中形状;运行时测试锁定原位结算、乱序完成与 callTime 配对。trajectory/waterfall 的子调用 span 从这对事件取得如实的计时。spill 边界划定([ptc-dispatch 日志 spill](2026-07-26-ptc-dispatch-log-spill.zh.md))则以结算事件作为唯一的边界点。 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 3b1f9f9754..b319069d32 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: a607d160f3529b4e7eb94e34be5a0a91a4010619 -2026-07-28-web-terminal-card.zh.md: 4c7fa5d35dfc85ce38db13402e3e56ccfb519584 +2026-07-28-web-terminal-card.md: 656e91a7a5f5e0e312726e1c02caa36cc06aa5e0 +2026-07-28-web-terminal-card.zh.md: cfcca327c1240c5d1b723fefc39e3cf962a8279e 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 a607d160f3..656e91a7a5 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 @@ -49,7 +49,7 @@ One premise of that split has since weakened: [tool rows stopped being details-p `TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. -A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the code-dispatch wire is that boundary's own change. +A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/ptc-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the ptc-dispatch wire is that boundary's own change. Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own. 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 4c7fa5d35d..cfcca327c1 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 @@ -51,7 +51,7 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode `TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。 -在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 code-dispatch wire 属于该边界自身的改动。 +在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/ptc-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 ptc-dispatch wire 属于该边界自身的改动。 内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。 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 7adf65e02a..0664c01644 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: 98e31d4192f7522f9d0e23bce56372a70f8c50b6 -2026-07-30-web-read-card-frontend.zh.md: 50e1ca8d05a38d13de15fdd6cdd713fd1758ffd4 +2026-07-30-web-read-card-frontend.md: ee03e966ce4475625dace7fb4f4bcee9dc4fa9c8 +2026-07-30-web-read-card-frontend.zh.md: 6c26e4f2c3747b142a62c2f490994cac6c56dd99 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 98e31d4192..ee03e966ce 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 @@ -36,7 +36,7 @@ Whole-row collapse/expand (defaulting every tool call to collapsed) is owned by `ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged. -A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/code-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form. +A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/ptc-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form. ## Testing 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 50e1ca8d05..6c26e4f2c3 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 @@ -36,7 +36,7 @@ Status: implemented `ui-primitives` 增加 `ReadBlock` 和 `highlightLines`;没有新的运行时依赖(shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。 -Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。按已发布的协议格式,`run_code` 子派发不会到达读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。 +Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。按已发布的协议格式,`run_code` 子派发不会到达读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts` 把 `tool/ptc-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。 ## Testing diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml deleted file mode 100644 index b811dee04f..0000000000 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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/feature/2026-07-31-code-mode-language-dispatch.md -2026-07-31-code-mode-language-dispatch.md: fe298b9a7cf14856f1bb10ac7f02d28ea3b67842 -2026-07-31-code-mode-language-dispatch.zh.md: a9a358e85c433fc406017cb2967fbd52e42bc1d4 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index d00a3fb636..db0e71accf 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 8d1af039c99fe6616745340fd1ef78b62e15b0ca -2026-07-31-even-out-shipped-tool-rosters.zh.md: b79486516dd3f7b54e27a3e7870ce42cd84a2ebd +2026-07-31-even-out-shipped-tool-rosters.md: f1ff22fd90e2936c8441d0627d2eb05286f10fbb +2026-07-31-even-out-shipped-tool-rosters.zh.md: a7bb27f4ab66a1ad76b1a2a0852ebbfbc7d3e00a diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 8d1af039c9..f1ff22fd90 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -52,7 +52,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th **Sandbox the TUI in the same change.** Rejected as a separate decision that does not belong in a roster change: the TUI mounts unrestricted executors, and replacing them alters what an existing surface does rather than what it offers. That decision needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting. -**Enable Code Mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. +**Enable PTC mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. **Mount an MCP server by default.** Rejected because a shipped default would have to name one, and any choice spawns a third-party child process on every user's machine outside the sandbox. The dependency ships instead. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index b79486516d..a7bb27f4ab 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -52,7 +52,7 @@ Status: implemented **在同一次改动里给 TUI 加沙箱。** 不予采纳,因为这是一个不属于工具清单改动的独立决定:TUI 挂的是不受限执行器,替换它们会改变一个既有 surface 做什么,而非它提供什么。这个决定需要自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升权请求在那里会 fail-closed,而不是弹出提示。 -**开启 Code Mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 +**开启 PTC mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 **默认挂一台 MCP 服务器。**否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。 diff --git a/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.i18n.yaml new file mode 100644 index 0000000000..b583e43bfc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.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/feature/2026-07-31-ptc-language-dispatch.md +2026-07-31-ptc-language-dispatch.md: c23e7b7ee1ebd85c98a92621721fca22eef48d63 +2026-07-31-ptc-language-dispatch.zh.md: 2b0110631761744a5067e541b4baf2a7d50a4d21 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md similarity index 88% rename from .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md rename to .agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md index fe298b9a7c..c23e7b7ee1 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md @@ -1,25 +1,25 @@ -# Agent Note: Code Mode language dispatch and the Python SDK renderer +# Agent Note: PTC mode language dispatch and the Python SDK renderer Status: implemented -English | [中文](2026-07-31-code-mode-language-dispatch.zh.md) +English | [中文](2026-07-31-ptc-language-dispatch.zh.md) ## Problem -Code Mode generated one SDK flavor: TypeScript. `ToolRuntime` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime. +PTC mode generated one SDK flavor: TypeScript. `ToolRuntime` hard-coded `renderToolsSdk` for the `tools:sdk` section and `requireCodeRuntime` rejected any `ctx.codeRuntime.language !== 'typescript'`. Adding a CPython backend means a program's source language is no longer fixed: the same visible tool registry must project a Python SDK when a Python runtime is loaded, and the model-facing `run_code` schema strings ("Execute a Python program …") must match the SDK section's language so the model never sees a TypeScript instruction over a Python runtime. -This is the tool-facing half of the multi-language Code Mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately. +This is the tool-facing half of the multi-language PTC mode split; the [code-runtime seam](../../../../packages/code-runtime/code-runtime/README.md) already carries `CodeRuntime.language`. This note owns only how `dsh-tools` dispatches on that field. The backend that implements `language: 'python'` is owned by its own note, delivered separately. ## Decision Language selection is a lookup on `ctx.codeRuntime.language`, resolved lazily at prompt assembly, against two parallel tables in `dsh-tools`: -- `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: code`/`both` runtime whose language is absent from the table, naming the known languages. -- `RUN_CODE_FLAVORS` (code-mode.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. +- `SDK_RENDERERS` (index.ts) maps a language to its `tools:sdk` renderer — `typescript → renderToolsSdk`, `python → renderToolsSdkPy`. The `tools:sdk` section reads the loaded runtime's language and picks the renderer; `requireCodeRuntime` rejects a `mode: ptc`/`both` runtime whose language is absent from the table, naming the known languages. +- `RUN_CODE_FLAVORS` (ptc.ts) maps a language to its two model-facing `run_code` strings (tool `description` and the `code` parameter description), so a language's SDK section and its transport schema always agree. Both tables are read with `Object.hasOwn` before use so a language named `toString`/`constructor` cannot resolve an inherited `Object.prototype` member as a renderer. The two guards differ in reachability: `SDK_RENDERERS`' in-callback guard is unreachable because `requireCodeRuntime` validated the same `const` table earlier in the same callback (it carries a `/* v8 ignore */`), while `RUN_CODE_FLAVORS`' guard is the primary, publicly reachable rejection — any language absent from the flavor table hits it through `run_code`'s language-aware getters, which the public `schemas()` reaches without passing `requireCodeRuntime` first; the test reads one of those getters off the definition directly, under a language absent from both tables. A language present in `SDK_RENDERERS` but not `RUN_CODE_FLAVORS` is drift the shared `CodeSdkLanguage` `satisfies` pins reject at `typecheck`, so it is not an input either guard can see; what the guards still own is a mounted runtime reporting a language absent from both tables. Schema emission reads the runtime through `peekRuntime()` rather than `requireRuntime()`: `undefined` (no runtime mounted, reached by definition readers and `schemas()`, of which the doc-catalog harvest is the only shipped one and none of which feeds a model because assembly passes `requireCodeRuntime` first) degrades to the TypeScript flavor, whereas a mounted unknown language fails loud — this is NOT the silent fallback rejected below, which concerns emitting a wrong-language SDK for a real runtime. Adding a backend language is three parallel edits — a `CodeSdkLanguage` member and the two table entries — plus its renderer and the prose that names the well-known values instead of deriving them (the seam's `dsh-code-runtime` README pair, its `CodeRuntime.language` JSDoc, and the `docs/subsystems/code-runtime.md` pair; this package's own README pair and its `Config.mode` JSDoc — no gate checks any of it), with no `agent-loop` or registry-structure change. -`code-mode.ts` depends only on the runtime Service Definition (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer is therefore independent of the Python protocol and backend — it needs only the service's `language` field. +`ptc.ts` depends only on the runtime Service Definition (`@deepseek-ai/dsh-code-runtime`), never on a concrete backend; dispatch is by `runtime.language` at run time. The tool layer is therefore independent of the Python protocol and backend — it needs only the service's `language` field. ### The Python SDK renderer @@ -32,7 +32,7 @@ The standard that cap serves is grammatical validity, and the boundary is delibe ## Alternatives considered - **A `language` config field on `ToolRuntime`.** Deployment would then have two places to name the language (the loaded runtime and the tools config) that can disagree; the loaded runtime is the single source of truth, so the registry reads it rather than duplicating it. -- **Importing the Python backend into `code-mode.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable. +- **Importing the Python backend into `ptc.ts` to detect it.** That would couple the tool layer to a concrete backend and force the protocol/backend PRs to land first. Runtime dispatch on `language` keeps the layer backend-agnostic and independently shippable. - **A default renderer for an unknown language.** A silent fallback would emit a TypeScript SDK over, e.g., a Ruby runtime — the model would see instructions in the wrong language. Failing loud at assembly is the repository's misconfiguration stance. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.zh.md similarity index 88% rename from .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md rename to .agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.zh.md index a9a358e85c..2b01106317 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.zh.md @@ -1,25 +1,25 @@ -# Agent Note: Code Mode 语言分发与 Python SDK 渲染器 +# Agent Note: PTC mode 语言分发与 Python SDK 渲染器 Status: implemented -[English](2026-07-31-code-mode-language-dispatch.md) | 中文 +[English](2026-07-31-ptc-language-dispatch.md) | 中文 ## 问题 -Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRuntime` 为 `tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK,而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。 +PTC mode 只生成一种 SDK 形态:TypeScript。`ToolRuntime` 为 `tools:sdk` 段硬编码了 `renderToolsSdk`,且 `requireCodeRuntime` 会拒绝任何 `ctx.codeRuntime.language !== 'typescript'`。引入 CPython 后端后,程序的源语言不再固定:同一个可见工具注册表在加载 Python 运行时时必须投射出 Python SDK,而面向模型的 `run_code` schema 字符串("Execute a Python program …")也必须与 SDK 段的语言一致,模型才不会在 Python 运行时下看到 TypeScript 指令。 -这是多语言 Code Mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.zh.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。 +这是多语言 PTC mode 拆分中面向工具的那一半;[代码运行时 seam](../../../../packages/code-runtime/code-runtime/README.zh.md) 已经携带 `CodeRuntime.language`。本 Note 只负责 `dsh-tools` 如何在该字段上分发。实现 `language: 'python'` 的后端由它自己的 Note 负责,单独交付。 ## 决策 语言选择就是对 `ctx.codeRuntime.language` 的查表,在提示词装配时惰性解析,查 `dsh-tools` 里两张平行的表: -- `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。 -- `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 +- `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: ptc`/`both` 运行时,并列出已知语言。 +- `RUN_CODE_FLAVORS`(ptc.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。 两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的回调内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——任何缺席 flavor 表的语言都经 `run_code` 的语言感知 getter 到达它,而公共 `schemas()` 抵达那些 getter 时并未先过 `requireCodeRuntime`;测试直读 definition 上的其中一个 getter,用的是对两张表都缺席的语言。「在 `SDK_RENDERERS` 里却不在 `RUN_CODE_FLAVORS` 里」这种漂移已由共享的 `CodeSdkLanguage` `satisfies` 在 `typecheck` 处拒绝,两个守卫都看不到这种输入;它们如今负责的是所挂载运行时报告了一门两张表都缺席的语言。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,由直读 definition 的读者与 `schemas()` 到达,其中 doc-catalog 采集是唯一已交付的一个,而它们都不会喂给模型,因为组装路径先过 `requireCodeRuntime`)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言是三处并列编辑——一个 `CodeSdkLanguage` 成员加两条表项——再加它的渲染器,以及点名已知值而非从中派生的散文(seam 侧的 `dsh-code-runtime` README 双语对、它的 `CodeRuntime.language` JSDoc 与 `docs/subsystems/code-runtime.md` 双语对;本包自己的 README 双语对与它的 `Config.mode` JSDoc,无任何 gate 检查其中任何一处),不动 `agent-loop`,也不动注册表结构。 -`code-mode.ts` 只依赖运行时 Service Definition(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于 Python 协议和后端——它只需要服务的 `language` 字段。 +`ptc.ts` 只依赖运行时 Service Definition(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于 Python 协议和后端——它只需要服务的 `language` 字段。 ### Python SDK 渲染器 @@ -32,7 +32,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRuntime` 为 `tools:sdk ## 考虑过的替代方案 - **在 `ToolRuntime` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真源,故注册表读取它而不复制它。 -- **把 Python 后端 import 进 `code-mode.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR(Pull Request)先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。 +- **把 Python 后端 import 进 `ptc.ts` 来检测它。** 那会把工具层耦合到具体后端,并迫使协议/后端 PR(Pull Request)先落地。按 `language` 运行时分发使该层保持后端无关、可独立发布。 - **为未知语言提供默认渲染器。** 静默回退会在比如 Ruby 运行时上发出 TypeScript SDK——模型会看到错误语言的指令。在装配处 fail loud 是本仓库对错误配置的立场。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml index a54ef0e945..b023332a1e 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.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-31-web-default-search.md -2026-07-31-web-default-search.md: efec6e1e94089d3bbd79296ff0eb2cb55ce005b3 -2026-07-31-web-default-search.zh.md: 825f0ee3f899c108039f6a0db59f0dfe2822cb72 +2026-07-31-web-default-search.md: dfd76176aa03741df38f60c6d30116f87ced4106 +2026-07-31-web-default-search.zh.md: 19fe20d7573accbcef45ab4db8c339335e2cc08b diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md index efec6e1e94..dfd76176aa 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.md @@ -34,4 +34,4 @@ The default mount does not create a Web-specific permission policy. `web_search` ## Consequences -Native model requests on every shared-base surface carry the `web_search` schema and search guidance; Web/headless Code Mode exposes the same search capability beneath `run_code`. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The shipped Web `cordis`, `code`, and `standard` presets additionally expose `web_fetch` with public-address enforcement and no per-call approval. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. Composition smokes pin the shared search roster and per-preset fetch choices; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. +Native model requests on every shared-base surface carry the `web_search` schema and search guidance; Web/headless PTC mode exposes the same search capability beneath `run_code`. Search adds a complete auxiliary model call and may use the native server tool multiple times; its exact secret-free request remains reconstructable from the initiating session log. The shipped Web `cordis`, `ptc`, and `standard` presets additionally expose `web_fetch` with public-address enforcement and no per-call approval. The Web snapshot lane boots the shipped tree, drives a replayed `web_search` call through the real DeepSeek provider against a local Messages fixture, asserts the durable auxiliary request and structured result, and pins the settled browser presentation. Composition smokes pin the shared search roster and per-preset fetch choices; the built composition dump pins the one-minute shipped search budget; provider tests pin missing, stored, and rotated credential behavior plus literal and ambient compatibility. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md index 825f0ee3f8..19fe20d757 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-default-search.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official` 与 `fetchProvider: http`,同时挂载 `dsh-web-search-deepseek`、`dsh-web-fetch-http`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。因此,共享 base 只会暴露 `web_search`,除非产品 preset 启用抓取;已交付的 Web `cordis`、`code` 与 `standard` preset 会启用抓取。显式提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。[Web 能力 seam 决策](../architecture/2026-06-24-web-capability-seam.zh.md)负责公开抓取安全策略与 Web preset 默认值。 +`apps/cli/config/base.cordis.yml` 明确挂载 `dsh-web`,配置 `searchProvider: deepseek-official` 与 `fetchProvider: http`,同时挂载 `dsh-web-search-deepseek`、`dsh-web-fetch-http`,并以 `fetch: false` 和 `searchTimeoutMs: 60000` 挂载 `dsh-tool-web`。因此,共享 base 只会暴露 `web_search`,除非产品 preset 启用抓取;已交付的 Web `cordis`、`ptc` 与 `standard` preset 会启用抓取。显式提供方 id 使选择不受注册顺序影响,同时个人覆盖层或 `--config` 覆盖层仍可替换或禁用这些配置项。已交付的一分钟预算用于覆盖一次辅助 DeepSeek Messages 请求及服务端检索,同时保持 `dsh-tool-web` 提供方无关的 30 秒默认值不变,以供自定义组合使用。[Web 能力 seam 决策](../architecture/2026-06-24-web-capability-seam.zh.md)负责公开抓取安全策略与 Web preset 默认值。 DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据引用。提供方在每次搜索内部通过可选的 `ctx.credentials` 服务解析该引用;只有未挂载该 seam 的组合才会回退到启动进程的环境变量,非空的 `apiKey` 字面值仍作为程序化配置的最后兜底。因此,由 Web 的 Models 页存储或轮换的密钥无需重启即可用于下一次搜索,提供方也无需保留该值。由于 `WebSearchProvider.available()` 是同步方法,它会将已安装解析器视为本地可用;若动态凭据缺失,操作会以提供方专属错误码 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,而稳定的工具 schema 仍保持注册。 @@ -34,4 +34,4 @@ DeepSeek 搜索使用与官方会话适配器相同的 `DEEPSEEK_API_KEY` 凭据 ## 后果 -每个共享 base surface 的原生模型请求都会携带 `web_search` schema 与搜索指引;Web/无头 Code Mode 通过 `run_code` 公开相同的搜索能力。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。已交付的 Web `cordis`、`code` 与 `standard` preset 还会暴露 `web_fetch`,实施公开地址强制校验且无需逐次审批。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。组合冒烟测试会固定共享搜索清单与各 preset 的抓取选择;构建后组合配置的转储固定已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 +每个共享 base surface 的原生模型请求都会携带 `web_search` schema 与搜索指引;Web/无头 PTC 模式 通过 `run_code` 公开相同的搜索能力。搜索会增加一次完整的辅助模型调用,并可能多次使用原生服务器工具;发起会话的日志仍可精确重建其不含密钥的请求。已交付的 Web `cordis`、`ptc` 与 `standard` preset 还会暴露 `web_fetch`,实施公开地址强制校验且无需逐次审批。Web 快照通道会启动已交付配置树,使用本地 Messages fixture(测试前置数据),经由真实 DeepSeek 提供方驱动一次回放的 `web_search` 调用,断言持久化的辅助请求与结构化结果,并固定最终浏览器呈现。组合冒烟测试会固定共享搜索清单与各 preset 的抓取选择;构建后组合配置的转储固定已交付的一分钟搜索预算;提供方测试固定缺失、已存储及已轮换凭据的行为,以及字面值与环境变量的兼容性。 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml index 0122488dac..29480a8bfb 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.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-05-per-agent-tool-presentation.md -2026-08-05-per-agent-tool-presentation.md: 1883a38f8541a524b9bf02628af7db3b015742d2 -2026-08-05-per-agent-tool-presentation.zh.md: fab41b81987f9b02b04f7260ed04893a59121e0c +2026-08-05-per-agent-tool-presentation.md: b85af656040ef365bb6ee6bfcd3d47686180d4d4 +2026-08-05-per-agent-tool-presentation.zh.md: e6d81ae072df905fddaa655da06abbb4f1ab73e6 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md index 1883a38f85..b85af65604 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -1,4 +1,4 @@ -# Agent Note: Per-agent tool presentation, and the `code` preset +# Agent Note: Per-agent tool presentation, and the `ptc` preset Status: implemented @@ -6,7 +6,7 @@ English | [中文](2026-08-05-per-agent-tool-presentation.zh.md) ## Problem -Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. Code Mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in Code Mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. +Agent presets compose an agent's tools per session, but not the FORM those tools reach the model in. PTC mode — one `run_code` tool plus a generated TypeScript SDK, replacing a call sequence with one program — was a deployment-wide `mode` field on the host's `dsh-tools` row. A deployment either ran every session in PTC mode or none, so the obvious product shape ("代码模式" beside 标准/极简/创造 in the preset picker) had nothing to hang on. The naive reading of "move tools down to the agent plane" does not work. `ctx.tools` has host-plane consumers that cannot follow it: `dsh-agent-loop` reads the registry's private scheduler seam, `dsh-apiproxy` reads its presenters to render tool cards, and every tool plugin registers into it. By the stack's own rule — a service moves into a preset only when ALL of its consumers move with it — the registry stays where it is. @@ -14,16 +14,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there. -`ToolRuntime.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-presentation` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. +`ToolRuntime.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `ptc` preset carries the `tool-presentation` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. Two consequences fell out and are load-bearing: - **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. -- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. +- **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a PTC mode was configured. Any agent may now select a PTC mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. -The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. +The SDK prompt section is registered globally by a ptc deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a ptc deployment free of an SDK section. -The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-presentation`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. +The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-presentation`, whose whole body is a `presentAs` call. A PTC mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting PTC mode against a runtime-less deployment fails where an operator can act. ## Alternatives considered @@ -41,6 +41,6 @@ The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-pr Two sessions in one process can now present differently, so "which tools does the model see" is no longer answerable from the deployment config alone; it requires the agent. Every diagnostic that quotes a mode now quotes the scope's, not the service's. -`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `code` preset. +`ctx.tools.schemas(agent)` remains the agent's CAPABILITY catalog and is unchanged by presentation — only the assembly's tools collapse. Tests asserting what the model receives must read the assembly; `web-agent-presets.spec.ts` asserts both sides of that distinction for the shipped `ptc` preset. -The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no code-mode preset; the shipped Web overlay carries one, the base composition does not. +The shipped roster is four presets (标准/代码/极简/创造), so any golden listing them moves. A deployment that composes no code runtime can compose no ptc preset; the shipped Web overlay carries one, the base composition does not. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md index fab41b8198..e6d81ae072 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 按 agent 的工具呈现方式,以及 `code` 预设 +# Agent Note: 按 agent 的工具呈现方式,以及 `ptc` 预设 Status: implemented @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。Code Mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 Code Mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 +agent preset 已经能按会话组装一个 agent 的工具,却管不了这些工具以何种**形态**抵达模型。PTC mode——一个 `run_code` 工具加一份生成的 TypeScript SDK,用一段程序替代一串调用——此前是宿主 `dsh-tools` 那一行上的部署级 `mode` 字段。一个部署要么所有会话都跑 PTC mode,要么一个都不跑,于是那个显而易见的产品形态(预设选择器里「代码模式」与标准/极简/创造并列)无处安放。 「把 tools 下沉到 agent 平面」这个字面读法行不通。`ctx.tools` 有一批跟不下来的宿主平面消费者:`dsh-agent-loop` 读它私有的调度器 seam,`dsh-apiproxy` 读它的 presenter 来渲染工具卡,每个工具插件都往里注册。按本 stack 自己的规则——只有**所有**消费者一起下沉,服务才能下沉——注册表必须留在原地。 @@ -14,16 +14,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些 把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。 -`ToolRuntime.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-presentation` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 +`ToolRuntime.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`ptc` preset 携带 `tool-presentation` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 有两个随之而来的结果,且都是承重的: - **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 -- **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 +- **保留名现在无条件生效。** `run_code` 此前只在配置了 PTC 模式时才被拒绝注册。如今任何 agent 都可能选择 PTC 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 -SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 +SDK 提示词段由 PTC 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 PTC 模式部署下选择退出」的 agent 不带 SDK 段。 -preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-presentation`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 +preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-presentation`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 PTC mode 的 preset,会在操作者能够动手的地方失败。 ## 考虑过的替代方案 @@ -41,6 +41,6 @@ preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-presentatio 同一进程内的两个会话现在可以有不同的呈现方式,因此「模型看到哪些工具」不再能只凭部署配置回答,必须给出 agent。凡是引用模式的诊断信息,现在引用的都是该 scope 的,而不是服务的。 -`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `code` 预设同时断言了这个区分的两侧。 +`ctx.tools.schemas(agent)` 仍然是该 agent 的**能力**清单,不受呈现方式影响——坍缩的只是 assembly 里的工具。断言「模型收到什么」的测试必须读 assembly;`web-agent-presets.spec.ts` 对随附的 `ptc` 预设同时断言了这个区分的两侧。 -随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 code 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 +随附的名单变成四个预设(标准/代码/极简/创造),因此任何列出它们的 golden 都会变动。未组装 code 运行时的部署无法组装任何 PTC 模式的 preset;随附的 Web overlay 带了一个,base 组装没有。 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index b0c70c4df4..4c276c6b0d 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: 19306a35fe709a04d94090a62056575b4d51f7bc -2026-08-10-minimal-read-image-tool.zh.md: c7562c433e909d1f81361c0ced56318795e6469e +2026-08-10-minimal-read-image-tool.md: 8880032b2648846df679ea8fa3301d182a95c06b +2026-08-10-minimal-read-image-tool.zh.md: aec34e19fc58037b031f7d4116d2fa664b2b45b5 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index 19306a35fe..8880032b26 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -15,7 +15,7 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged - **`read_image` reads a filesystem path.** Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`. The tool result contains metadata and an `ImageBlock`. - **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). - **Registration is composition-conditional, execution is route-gated.** The tools register only under `ctx.inject(['attachments'], …)`. Before I/O, the strict gate resolves the calling route through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A text-only route can still consume prior durable images because the shared LLM runtime projects them to placeholders at request assembly. -- **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. +- **PTC mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. - **llm-replay models may declare `inputModalities`**, which lets keyless ACP snapshots cover the image-capable result and the text-only refusal. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index c7562c433e..aec34e19fc 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -15,7 +15,7 @@ Status: implemented - **`read_image` 读取文件系统路径。** 扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型,附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动。工具结果包含元数据和一个 `ImageBlock`。 - **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 - **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册。执行时在 I/O 之前通过 `ctx.llm.resolveModelInfo` 解析调用路由,并要求 `inputModalities` 包含 `image`;能力未知即拒绝。纯文本路由仍可使用此前的持久图片,因为共享 LLM 运行时会在请求组装时把图片投影为占位符。 -- **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 +- **PTC mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 - **llm-replay 模型可以声明 `inputModalities`**,因此 keyless ACP 快照可以覆盖支持图片的结果和纯文本拒绝。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml index c72c3384fa..2061bd878b 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md -2026-07-26-web-syntax-highlighting-shiki.md: 967df645db3df0a33f9cd1f34e22e912a348c30e -2026-07-26-web-syntax-highlighting-shiki.zh.md: 3ac1d6bc17954418566b683c2a1d64dfb79f3434 +2026-07-26-web-syntax-highlighting-shiki.md: ba50318d306426d886af25f01eba616ab919d237 +2026-07-26-web-syntax-highlighting-shiki.zh.md: 5533bcb5348db152a8f9466eae7d3d8d9a726162 diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md index 967df645db..ba50318d30 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-web-syntax-highlighting-shiki.zh.md) -> Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the Code Mode UI stack; the [chat sub-call rows note](../feature/2026-07-26-code-mode-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). +> Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the PTC mode UI stack; the [chat sub-call rows note](../feature/2026-07-26-ptc-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). ## Problem diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md index 3ac1d6bc17..5533bcb534 100644 --- a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-web-syntax-highlighting-shiki.md) | 中文 -> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表约定与各消费表面。本篇是 Code Mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-code-mode-chat-subcall-rows.zh.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则由 [Web 样式体系裁决](2026-07-19-web-styling-system.zh.md)规定。 +> 范围:web client 唯一的一套语法高亮体系——依赖裁决、单例形态、token 表约定与各消费表面。本篇是 PTC mode UI 堆叠 PR(Pull Request)链的第五个 PR;[chat 子调用行 Agent Note](../feature/2026-07-26-ptc-chat-subcall-rows.zh.md)交付了 `run_code` 程序正文,而本体系存在的意义正是让它可读。样式的基本规则由 [Web 样式体系裁决](2026-07-19-web-styling-system.zh.md)规定。 ## 问题 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index b238e98e3e..efabfb37a1 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: ad8b7fcf5c79b5e3512d7fd908a41e2de47a7bce -2026-07-20-remove-stdio-and-echo-agents.zh.md: be30a44a3bdf807cc5dc6db54617c0b31e28a02b +2026-07-20-remove-stdio-and-echo-agents.md: cb0f53737a89c5324eb3712c22380b02cef7cb77 +2026-07-20-remove-stdio-and-echo-agents.zh.md: f7374fa6fba0b91a1af29f9a934d35d415c3affb diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index ad8b7fcf5c..cb0f53737a 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless expected-output test preserves assembled canonical events without creating a second CLI contract. Code Mode runs through the headless profile's `DSH_TOOLS_MODE=code` composition. Time-context integration uses its package-owned Loader composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless expected-output test preserves assembled canonical events without creating a second CLI contract. PTC mode runs through the headless profile's `DSH_TOOLS_MODE=ptc` composition. Time-context integration uses its package-owned Loader composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index be30a44a3b..f7374fa6fb 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -30,7 +30,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 预期输出测试保留组装后的规范事件,而不会创建第二套 CLI(命令行界面)约定。Code Mode 通过 headless profile 的 `DSH_TOOLS_MODE=code` 组合运行。时间上下文集成通过包自有 Loader 组合执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 预期输出测试保留组装后的规范事件,而不会创建第二套 CLI(命令行界面)约定。PTC mode 通过 headless profile 的 `DSH_TOOLS_MODE=ptc` 组合运行。时间上下文集成通过包自有 Loader 组合执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index 8c085a2343..b7f2ca1524 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.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/simplification/2026-07-22-plan-specific-collaboration-state.md -2026-07-22-plan-specific-collaboration-state.md: 20c363d00cfe2bcc2f01af101370f5214c948361 -2026-07-22-plan-specific-collaboration-state.zh.md: 63157b1959236e728e94a38d7ca6397a975595ef +2026-07-22-plan-specific-collaboration-state.md: 1387e69e628c5fb33b61a06214ffd67669668a13 +2026-07-22-plan-specific-collaboration-state.zh.md: 47d36eb97a85d483e40afb5fb9c44f1bf2c92d5e diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index 20c363d00c..1387e69e62 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -26,7 +26,7 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei `plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly at initial or continuation pre-step, or on a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. -The active state contributes the deployment's section at first-party prompt order 500. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. +The active state contributes the deployment's section at first-party prompt order 500. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the PTC mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. ### Reviewed exit @@ -59,7 +59,7 @@ The tool renders the submitted plan as a generic card titled by its first headin ## Verification -- Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and Code Mode schemas, review outcomes, and invariant coverage through the boolean service. +- Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and PTC mode schemas, review outcomes, and invariant coverage through the boolean service. - Command tests cover bare `/plan`, `/plan `, active `/plan off`, pending-entry cancellation, inactive idempotence, absence of `/mode` and `/review`, and effect-scoped removal. - The keyless TUI scenarios enter through `/plan `, leave through `/plan off`, and prove that each committed `plan/mode` precedes the request header it changes, the entry message is logged under plan guidance, and the post-exit request omits that guidance. - The complete `exit_plan_mode` review arc is package-tested but has no assembled-application snapshot after the interactive ACP scenarios were retired; current keyless TUI scenarios cover command entry and direct exit only. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index 63157b1959..47d36eb97a 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -26,7 +26,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` `plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩(compaction)都能恢复该状态,无需实时镜像。spawn 出的 agent(智能体)初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在初始或续步 pre-step 时,或在请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 -激活状态在 first-party 提示词顺序 500 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 +激活状态在 first-party 提示词顺序 500 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 PTC mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 ### 经评审的退出 @@ -59,7 +59,7 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` ## 验证 -- 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 Code Mode schema、评审结果和不变式。 +- 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 PTC mode schema、评审结果和不变式。 - 命令测试覆盖不带参数的 `/plan`、`/plan `、激活状态下的 `/plan off`、取消待生效的进入选择、未激活状态下的幂等性、不存在 `/mode` 和 `/review`,以及随 effect 作用域移除。 - 无密钥 TUI 场景通过 `/plan ` 进入、通过 `/plan off` 退出,并证明每个已提交的 `plan/mode` 都先于其所改变的请求头,进入消息在 plan 引导下记录到日志,且退出后的请求不含该引导。 - 完整的 `exit_plan_mode` 评审流程有包测试,但交互式 ACP 场景退役后没有组装应用快照;当前无密钥 TUI 场景只覆盖命令进入和直接退出。 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml index a6ce4ad6b0..12184063d7 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.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/simplification/2026-07-28-remove-synthetic-log-only-turns.md -2026-07-28-remove-synthetic-log-only-turns.md: 2b0add1a916021cfc1790c8f4fd5684305d81be8 -2026-07-28-remove-synthetic-log-only-turns.zh.md: 59fa8e4fcc6301cf6d04cab0d572e0c4ff15731b +2026-07-28-remove-synthetic-log-only-turns.md: 6fe23b0b34c49cc79d912f86e9ffe548b8e08d19 +2026-07-28-remove-synthetic-log-only-turns.zh.md: ccdda3f7606bc160dbb4d896348ed49bd046c6a7 diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md index 2b0add1a91..6fe23b0b34 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.md @@ -36,7 +36,7 @@ The historical [universal turn-enclosure decision](../../archived/architecture/2 ## Verification -Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, Code Mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. +Core invariant tests accept an unknown plugin event between turns while continuing to reject built-in execution events there. Hook, plan-mode, PTC mode dispatch, and approval invariant companions reject their execution-scoped events when no turn is open; the compaction companion separately accepts a balanced `turn: null` manual bracket between turns and requires numeric owners to match an open turn. Session-title service tests pin one direct fallback event under concurrent refresh, detached-session rejection, and newest-revision acceptance. JSONL and SQLite round trips preserve a title appended after `turn/end` through the persistence lifecycle drain, and fork tests retain a standalone log-only tail while rejecting boundaries inside an open turn. A keyless assembled ACP snapshot delays the model-backed title until after `turn/end` and pins one standalone provider title with no synthetic turn. Generated API and type-equivalence catalogs contain no removed symbol. ## Consequences diff --git a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md index 59fa8e4fcc..ccdda3f760 100644 --- a/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 验证 -核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、Code Mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 +核心不变量测试会接受轮次之间的未知插件事件,同时继续拒绝位于该处的内置执行事件。钩子、plan-mode、PTC mode 分发和审批的不变量配套组件会在没有开放轮次时拒绝其执行作用域事件;压缩配套组件则另外接受轮次之间平衡的 `turn: null` 手动标记对,并要求数字 owner匹配一个开放轮次。会话标题服务测试会在并发刷新、拒绝已脱离会话和接受最新修订的场景下,固定一个直接追加的回退事件。JSONL 和 SQLite 往返测试会通过持久化生命周期排空保留追加在 `turn/end` 之后的标题;fork 测试会保留独立纯日志尾部,同时拒绝位于开放轮次内的边界。一个无密钥、经完整组装的 ACP(Agent Client Protocol)快照会将模型生成的标题延迟到 `turn/end` 之后,并固定一个不含合成轮次的独立提供方标题。生成的 API 和类型等价性目录不含任何已移除符号。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index e65f1cd3b2..4125e5a52e 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.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/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: 674ac9c6a386fc6402d18b06b638ce8209855b15 -2026-07-29-shared-base-config-overlays.zh.md: 6001cfbadb0b8e21528903d64572d1e4d09e9db2 +2026-07-29-shared-base-config-overlays.md: de9dcafeab46c21a4fea85f36d73e3fbc9736d9a +2026-07-29-shared-base-config-overlays.zh.md: 0e5b76df33be433391572f08b639dd85c0f30b8e diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index 674ac9c6a3..de9dcafeab 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -24,13 +24,13 @@ Precedence is list order, last write winning per row: base, then the surface ove A patch replaces its target row's whole `config` rather than merging. Therefore, a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as the launcher-owned identity record documented. -The TUI tests live in `apps/cli/tests/`, the Cordis-toolset e2e in `packages/extensions/tool-cordis/tests/`, and the supported Code Mode demo runs `dsh --profile headless` with `DSH_TOOLS_MODE=code`. +The TUI tests live in `apps/cli/tests/`, the Cordis-toolset e2e in `packages/extensions/tool-cordis/tests/`, and the supported PTC mode demo runs `dsh --profile headless` with `DSH_TOOLS_MODE=ptc`. ## Alternatives considered **Leave both trees flat and duplicated.** Rejected: 43 rows maintained twice is the defect, and a gate asserting they stay identical would freeze the duplication rather than remove it. -**Nest the overlays as includes (`code-mode` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. +**Nest the overlays as includes (`ptc` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. **Put the union of all rows in the base and have each overlay disable what it does not want.** Rejected: the base stops meaning "shared", and each surface carries rows it exists only to switch off. @@ -46,7 +46,7 @@ A patch whose `id` matches no row stays a no-op rather than an error. That is de ## Verification -Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. Code Mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application. +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. PTC mode remains covered by the ACP overlay and programmatic TUI snapshots rather than a separate shipped TUI application. All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/boot/app-boot/tests/config-reload.spec.ts`). diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index 6001cfbadb..0e5b76df33 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -24,13 +24,13 @@ Status: implemented patch 会整体替换目标配置项的 `config` 而不合并。因此,取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,正如启动器持有身份的记录所述。 -TUI 测试位于 `apps/cli/tests/`,Cordis 工具集 e2e 位于 `packages/extensions/tool-cordis/tests/`,受支持的 Code Mode demo 则以 `DSH_TOOLS_MODE=code` 运行 `dsh --profile headless`。 +TUI 测试位于 `apps/cli/tests/`,Cordis 工具集 e2e 位于 `packages/extensions/tool-cordis/tests/`,受支持的 PTC mode demo 则以 `DSH_TOOLS_MODE=ptc` 运行 `dsh --profile headless`。 ## 备选方案 **保留两棵平铺且重复的树。** 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。 -**把 overlay 嵌套成 include(`code-mode` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 +**把 overlay 嵌套成 include(`ptc` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 **把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。** 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。 @@ -46,7 +46,7 @@ TUI 测试位于 `apps/cli/tests/`,Cordis 工具集 e2e 位于 `packages/exten ## 验证 -组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能完全就绪,且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。Code Mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用。 +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能完全就绪,且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。PTC mode 继续由 ACP overlay 与程序化 TUI 快照覆盖,而不再维护独立交付的 TUI 应用。 全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/boot/app-boot/tests/config-reload.spec.ts` 覆盖)。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml index 277f33f997..4e47e34616 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.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/simplification/2026-08-10-default-presets-single-editor.md -2026-08-10-default-presets-single-editor.md: 82f254079080aeb88d76f4e7cc2c7ab195646ec4 -2026-08-10-default-presets-single-editor.zh.md: 1d89662bcc1acd664e7014a65b54110beb5ffb5c +2026-08-10-default-presets-single-editor.md: 3d1c1dea4f9fcc676a8146033f7785dfd3aaf481 +2026-08-10-default-presets-single-editor.zh.md: 5212156a6a3c11e16165eca04bd141337380e172 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md index 82f2540790..3d1c1dea4f 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md @@ -10,7 +10,7 @@ The `standard`, `code`, and `cordis` presets exposed both the `read`/`write`/`ed ## Decision -The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. Code Mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. +The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. PTC mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. This decision narrows the preset roster rather than removing the tool package or its Python runtime support. The earlier [shared-roster decision](../feature/2026-07-31-even-out-shipped-tool-rosters.md) continues to own why surface-neutral tools live in preset composition; this note owns the editor exception. @@ -22,4 +22,4 @@ This decision narrows the preset roster rather than removing the tool package or ## Consequences -General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the Code Mode SDK, while the minimal assertions continue to pin its presence. +General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the PTC mode SDK, while the minimal assertions continue to pin its presence. diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md index 1d89662bcc..5212156a6a 100644 --- a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 Code Mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 +`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 PTC mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 此决策收窄 preset 工具清单,不移除工具包及其 Python 运行时支持。较早的[共享清单决策](../feature/2026-07-31-even-out-shipped-tool-rosters.zh.md)继续说明与 surface 无关的工具为何归 preset 组合所有;本记录说明编辑器例外。 @@ -22,4 +22,4 @@ Status: implemented ## 后果 -通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 Code Mode SDK 中,同时 minimal 断言继续固定其存在。 +通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 PTC mode SDK 中,同时 minimal 断言继续固定其存在。 diff --git a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.i18n.yaml index 70c3619952..9768def496 100644 --- a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.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/simplification/2026-08-24-owner-local-profile-tests-and-guides.md -2026-08-24-owner-local-profile-tests-and-guides.md: d41bac22bfca907a5601d8fdb279b9a20ecc50d4 -2026-08-24-owner-local-profile-tests-and-guides.zh.md: 87ddfb670ef1375ae48bee36f92f71fd103f461d +2026-08-24-owner-local-profile-tests-and-guides.md: 26a62dd9c28a55d5195ab9a8a56c5369bf521362 +2026-08-24-owner-local-profile-tests-and-guides.zh.md: fa65c09b8b886b1bf442ff5a52976e06032bb75c diff --git a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md index d41bac22bf..26a62dd9c2 100644 --- a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md +++ b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.md @@ -14,7 +14,7 @@ There is no top-level `examples/` tree. Named `dsh` profiles are the only Node a Optional user overlays are shipped assets under `apps/cli/config/examples/`, where their bare plugin names resolve through the CLI application manifest. The GitHub review, Schedule, memory MCP, and runtime Cordis guides live under `docs/user/` and link those assets. The runnable Python SDK program and minimal overlay live under `python/sdk/examples/`. -The `demo:acp` and `demo:cordis` scripts are absent. ACP starts through `dsh --profile acp`; the Cordis guide starts `dsh web` with its explicit overlay. `demo:code-mode` remains as a thin wrapper over `dsh --profile headless` with `DSH_TOOLS_MODE=code`. +The `demo:acp` and `demo:cordis` scripts are absent. ACP starts through `dsh --profile acp`; the Cordis guide starts `dsh web` with its explicit overlay. `demo:ptc` remains as a thin wrapper over `dsh --profile headless` with `DSH_TOOLS_MODE=ptc`. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md index 87ddfb670e..fa65c09b8b 100644 --- a/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-24-owner-local-profile-tests-and-guides.zh.md @@ -14,7 +14,7 @@ Status: implemented 可选用户 overlay 作为交付资产位于 `apps/cli/config/examples/`,其中的裸插件名通过 CLI 应用 manifest 解析。GitHub 评审、Schedule、记忆 MCP 与运行时 Cordis 指南位于 `docs/user/` 并链接这些资产。可运行的 Python SDK 程序与极简 overlay 位于 `python/sdk/examples/`。 -仓库不存在 `demo:acp` 与 `demo:cordis` 脚本。ACP 通过 `dsh --profile acp` 启动;Cordis 指南使用显式 overlay 启动 `dsh web`。`demo:code-mode` 继续作为薄 wrapper,以 `DSH_TOOLS_MODE=code` 运行 `dsh --profile headless`。 +仓库不存在 `demo:acp` 与 `demo:cordis` 脚本。ACP 通过 `dsh --profile acp` 启动;Cordis 指南使用显式 overlay 启动 `dsh web`。`demo:ptc` 继续作为薄 wrapper,以 `DSH_TOOLS_MODE=ptc` 运行 `dsh --profile headless`。 ## 考虑过的替代方案 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 5c561d9188..7c63922271 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: dbf73976b606a4a45202c1b29b79f5cfbd77ac1c -2026-08-04-task-surface.zh.md: ecf8764b7b1da7a4a874dd78c6f69df80fb36f36 +2026-08-04-task-surface.md: db4472e98146902cad59112cee3a9098cb736fa1 +2026-08-04-task-surface.zh.md: 8487c3f5f5b40a55a709373b83eb7425ef5e532f diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index dbf73976b6..db4472e981 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -81,7 +81,7 @@ Limits are schema-backed configuration on the Task Surface service. The initial `show_task_surface` accepts `{ model: TaskSurfaceModelV1 }`. The Host parses and normalizes the complete model, rejects the call when that Session already has an open Task Surface, mints `surfaceId`, and returns canonical `{ surfaceId, model }` with the normalized model. `presentationMeta` persists `value.model`, so the projector and executor cannot disagree about normalization. The Native result names the Surface and explains that an ordinary message bypasses it when the client cannot render the panel. The tool then calls `exec.concludeTurn()` so the agent does not continue past the requested human checkpoint. -The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because Code Mode dispatch is nested and cannot carry its presentation metadata to the outer result. +The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because PTC mode dispatch is nested and cannot carry its presentation metadata to the outer result. The browser-safe domain package imports the type-only `Branded` primitive from `@deepseek-ai/dsh-brand` and owns all three Task Surface IDs. The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`: diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index ecf8764b7b..8487c3f5f5 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -81,7 +81,7 @@ Task Surface 服务通过受 schema 校验的配置定义限制。初始默认 `show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface,则拒绝调用;否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }`。`presentationMeta` 持久化 `value.model`,使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface,并说明客户端无法渲染面板时,可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。 -工具定义省略 `isConcurrencySafe`。根据现有工具注册表约定,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 Code Mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 +工具定义省略 `isConcurrencySafe`。根据现有工具注册表约定,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 PTC mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 浏览器安全的领域包从 `@deepseek-ai/dsh-brand` 以仅类型方式导入 `Branded` 原语,并拥有全部三个 Task Surface ID。根据[规范工具输出约定](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml index b84fc76dbd..7027d11d80 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md -2026-07-04-prune-dead-core-spine-api.md: ae6ea2d763c1f84b32d1a24bae32f412c77c9976 -2026-07-04-prune-dead-core-spine-api.zh.md: 8e76827de10e119ed1e0d9fbf8a44e8eaf21575c +2026-07-04-prune-dead-core-spine-api.md: ecf4e3aa032e47a26d65edec3817e2a50026da40 +2026-07-04-prune-dead-core-spine-api.zh.md: 81cee28d9f9819802b2276b90a4db51035046e1c diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md index ae6ea2d763..ecf4e3aa03 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.md @@ -27,7 +27,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | `CompactionResult.startSeq`, `summarySeq`, `endSeq`, and `summary` | The production consumer reads only shadowed range/seq/token accounting; the durable log owns summary and event identity. | Remove the four result echoes while keeping both shared transcript renderers. | | `BasicCompactionEngine` estimation/summarization visibility | No outside production caller invokes the five methods; the implemented Agent Note names only `estimateContentTokens()` and `summarize()` as subclass hooks. | Make those two `protected` and the three orchestration-only estimators private. | | `CodeLogEntry.source`/`level` and `RunCodeMeta.dispatches` | Every production consumer maps logs to text; no presenter/model path reads the other fields or the persisted dispatch count. | Make code-runtime logs strings (or text-only entries) and remove result-meta dispatch plumbing; keep the local counter that mints deterministic dispatch ids. | -| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while Code Mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | +| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while PTC mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | | `ToolNotFoundError.toolName`, `SystemPrompt.config`, and `BashTask.command` | Each stored public value has no production reader. | Drop the unread field while retaining error messages, resolved configuration behavior, and task lifecycle. | | Backend package-root implementation helpers | The exact inventory below is called only through relative same-package imports. Production namespace imports mount the retained plugin contract without reading these properties; named root consumers are tests. | Retain each adapter/provider/service and its config/error contract; stop exporting the listed helper functions/constants at package roots. | | Consumer package-root implementation helpers | The exact inventory below has only same-package production callers. Production namespace imports mount plugin contracts without reading helper properties; named root consumers are tests. | Retain plugin contracts and stable error codes; move tests to package-local modules or public behavior and stop exporting the listed helpers at package roots. | diff --git a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md index 8e76827de1..81cee28d9f 100644 --- a/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md +++ b/.agents/notes/proposed/simplification/2026-07-04-prune-dead-core-spine-api.zh.md @@ -27,7 +27,7 @@ Status: proposed | `CompactionResult.startSeq`、`summarySeq`、`endSeq` 与 `summary` | 生产消费方只读取 shadowed range/seq/token 统计;持久日志拥有 summary 和事件标识。 | 移除四个结果回显,保留两个共享的 transcript(文本记录)渲染器。 | | `BasicCompactionEngine` 的估算/摘要方法可见性 | 没有包外生产调用者调用这五个方法;已实现的 Agent Note 只将 `estimateContentTokens()` 和 `summarize()` 命名为子类钩子。 | 将这两个方法改为 `protected`,其余三个编排专用的估算器改为 private。 | | `CodeLogEntry.source`/`level` 与 `RunCodeMeta.dispatches` | 每个生产消费方都将日志映射为文本;没有 presenter/模型路径读取其他字段或持久化的 dispatch 计数。 | 将 code-runtime 日志改为字符串(或纯文本条目),移除 result-meta 的 dispatch 管道;保留用于生成确定性 dispatch id 的本地计数器。 | -| `CodeRuntime.language` 与 `CodeRuntime.isolation` | worker 后端提供唯一的生产值,而 Code Mode 及其他所有生产调用方只调用 `run()`。 | 移除未读描述符,同时保留 worker 的语言、隔离、预算、取消与资源释放行为。 | +| `CodeRuntime.language` 与 `CodeRuntime.isolation` | worker 后端提供唯一的生产值,而 PTC mode 及其他所有生产调用方只调用 `run()`。 | 移除未读描述符,同时保留 worker 的语言、隔离、预算、取消与资源释放行为。 | | `ToolNotFoundError.toolName`、`SystemPrompt.config` 与 `BashTask.command` | 每个存储的公开值都没有生产读取者。 | 移除未读字段,保留错误消息、已解析的配置行为和任务生命周期。 | | 后端包根实现辅助函数 | 下方精确清单仅通过相对路径的同包导入调用。生产命名空间导入挂载的是保留的插件约定,不读取这些属性;包根命名导入的消费方都是测试。 | 保留每个适配器/提供方/服务及其配置/错误约定;停止在包根导出所列辅助函数/常量。 | | 消费方包根实现辅助函数 | 下方精确清单只有同包生产调用者。生产命名空间导入挂载的是插件约定,不读取辅助属性;包根命名导入的消费方都是测试。 | 保留插件约定和稳定的错误码;将测试迁移到包内模块或公开行为,停止在包根导出所列辅助函数。 | diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.i18n.yaml b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.i18n.yaml index 201991e0a3..fdadd105b9 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.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/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md -2026-07-12-prune-unused-skill-registry-api.md: c84c6d8d61e13ebe7e1a5d3d29260637e38dbf9f -2026-07-12-prune-unused-skill-registry-api.zh.md: 0fada10b8c086b982dd402adccd42b9e48d129a7 +2026-07-12-prune-unused-skill-registry-api.md: 23fb0163296a1cf6cd8700ad745f60e52371a3b1 +2026-07-12-prune-unused-skill-registry-api.zh.md: e1496aab7bc77dc04600a95bea8a5db3f8955053 diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md index c84c6d8d61..23fb016329 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.md @@ -21,7 +21,7 @@ Amend the skill-system Agent Note, README, JSDoc, catalogs, and tests. Agent-sco ## Acceptance criteria - Skill collection has one provider-backed path, a cwd-only completed-cache key, and a revision epoch only for in-flight invalidation; retained skill fields have a production reader or a recorded deliberate extension contract. -- Agent-scoped prompt sections, variables, tool providers, tool guards, and structured-output commit behavior in native and Code Mode remain unchanged. +- Agent-scoped prompt sections, variables, tool providers, tool guards, and structured-output commit behavior in native and PTC mode remain unchanged. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. ## Risks diff --git a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md index 0fada10b8c..e1496aab7b 100644 --- a/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md +++ b/.agents/notes/rejected/simplification/2026-07-12-prune-unused-skill-registry-api.zh.md @@ -21,7 +21,7 @@ skill(技能)服务的嵌入式运行时子系统中,`ctx.skills.register( ## 验收标准 - skill 收集只有一条提供方驱动的路径,已完成缓存仅以 cwd 为键,revision epoch 仅用于使进行中的发现操作失效;保留的 skill 字段要么有生产读取方,要么有记录在案的有意扩展约定。 -- agent 作用域的系统提示词段、变量、工具提供方、工具守卫,以及原生模式和 Code Mode 下的 structured-output 提交行为保持不变。 +- agent 作用域的系统提示词段、变量、工具提供方、工具守卫,以及原生模式和 PTC mode 下的 structured-output 提交行为保持不变。 - 类型检查、覆盖率、快照、doc-sync(文档同步门禁)、module-graph 校验、构建与 hygiene 全部通过。 ## 风险 diff --git a/AGENTS.md b/AGENTS.md index a92ef8a9cf..956e28ea45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gate pnpm run test:docs # quick documentation checks (no build; doc-quick aggregate) pnpm run website:build # VitePress build (doubles as dead-link check) pnpm dsh --profile headless "task" # run one task from source (needs DEEPSEEK_API_KEY) -pnpm run demo:code-mode -- "task" # headless Code Mode run (needs key) +pnpm run demo:ptc -- "task" # headless PTC mode run (needs key) ``` ### Host sandbox failures diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 616dcd1909..ce770592a0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies diff --git a/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts similarity index 91% rename from apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts rename to apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index ca34a38628..ea761cce71 100644 --- a/apps/cli/tests/profiles/headless/tests/code-mode.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -27,7 +27,7 @@ import CordisHostRunner from '@deepseek-ai/dsh-cordis-host-runner' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' /** - * With-key Code Mode proof: a real model receives only `run_code`, composes two + * With-key PTC mode proof: a real model receives only `run_code`, composes two * sub-calls, writes a file, and returns curated output while the log records * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. */ @@ -54,7 +54,7 @@ async function codeModeHarness(cwd: string): Promise { await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) await harness.plugin(SystemPrompt, { persona: PERSONA }) - await harness.plugin(ToolRuntime, { mode: 'code' }) + await harness.plugin(ToolRuntime, { mode: 'ptc' }) await harness.plugin(AgentRegistry) await harness.plugin(AgentLoop, { agents: [] }) await harness.plugin(LlmDeepSeek) @@ -71,7 +71,7 @@ async function workspaceCodeModeHarness(): Promise { await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) await harness.plugin(SystemPrompt, { persona: PERSONA }) - await harness.plugin(ToolRuntime, { mode: 'code' }) + await harness.plugin(ToolRuntime, { mode: 'ptc' }) await harness.plugin(AgentRegistry) await harness.plugin(LocalFileSystem, { cwd: '/' }) await harness.plugin(ToolFs) @@ -85,7 +85,7 @@ async function workspaceCodeModeHarness(): Promise { let keylessCall = 0 const testToolSignal = new AbortController().signal -/** Execute one outer Code Mode call through the real registry and worker. */ +/** Execute one outer PTC mode call through the real registry and worker. */ function runCode( harness: Context, code: string, @@ -115,7 +115,7 @@ function completion(result: ToolExecutionResult): unknown { async function typedCodeModeHarness(): Promise { const harness = new Context() await harness.plugin(SystemPrompt) - await harness.plugin(ToolRuntime, { mode: 'code' }) + await harness.plugin(ToolRuntime, { mode: 'ptc' }) await harness.plugin(WorkerThreadCodeRuntime, {}) return harness } @@ -132,7 +132,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { return harness } -describe('Code Mode typed values: keyless real-worker contracts', () => { +describe('PTC mode typed values: keyless real-worker contracts', () => { it('crosses a large intermediate value intact and exposes only typed tool failure fields', async () => { ctx = await typedCodeModeHarness() ctx.tools.register(defineTool({ @@ -187,7 +187,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }) it('returns a background job id, settles the outer run, and polls that id to completion', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-background-')) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-background-')) ctx = await backgroundCodeModeHarness(workdir) const jobId = completion(await runCode(ctx, ` @@ -210,7 +210,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }, 15_000) it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-task-cancel-')) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-task-cancel-')) ctx = await backgroundCodeModeHarness(workdir) const pre = new AbortController() @@ -247,7 +247,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }, 15_000) it('keeps foreground bash coupled to the outer signal', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-foreground-cancel-')) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-foreground-cancel-')) ctx = await backgroundCodeModeHarness(workdir) const controller = new AbortController() const startedAt = Date.now() @@ -266,16 +266,16 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { await ctx.plugin(CordisHostRunner) await ctx.plugin(ToolCordis) const agent = { - id: SessionId('code-mode-cordis'), + id: SessionId('ptc-cordis'), session: { append: vi.fn() }, } as unknown as Agent const value = completion(await runCode(ctx, ` const activeDefinition = await tools.cordis_define({ plugin: { kind: 'new', idPrefix: 'active' }, - name: 'active-code-mode-plugin', + name: 'active-ptc-plugin', purpose: 'prove an active Host half', - code: { host: "return { name: 'active-code-mode-plugin', apply(ctx) {} }" }, + code: { host: "return { name: 'active-ptc-plugin', apply(ctx) {} }" }, }); const active = await tools.cordis_run({ pluginId: activeDefinition.pluginId, @@ -284,9 +284,9 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { }); const pendingDefinition = await tools.cordis_define({ plugin: { kind: 'new', idPrefix: 'queue' }, - name: 'pending-code-mode-plugin', + name: 'pending-ptc-plugin', purpose: 'prove a Host half waiting for a Service', - code: { host: "return { name: 'pending-code-mode-plugin', inject: ['missing-code-mode-service'], apply(ctx) {} }" }, + code: { host: "return { name: 'pending-ptc-plugin', inject: ['missing-ptc-service'], apply(ctx) {} }" }, }); const pending = await tools.cordis_run({ pluginId: pendingDefinition.pluginId, @@ -329,7 +329,7 @@ describe('Code Mode typed values: keyless real-worker contracts', () => { packageId: 'pkg-2', pluginRunId: 'run-2', status: 'waiting', - waitingFor: ['missing-code-mode-service'], + waitingFor: ['missing-ptc-service'], }, removed: { pluginId: 'active-1', wasRunning: true }, beforeContainsId: true, @@ -349,11 +349,11 @@ function waitForIdle(harness: Context, agent: Agent): Promise { }) } -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a program over real tools', () => { +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a program over real tools', () => { it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-e2e-')) ctx = await codeModeHarness(workdir) - const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-ptc'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ content: [{ @@ -396,14 +396,14 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p }, 180_000) it('projects nested workspace instructions discovered by an fs sub-call', async () => { - workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-workspace-e2e-')) + workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-workspace-e2e-')) await mkdir(join(workdir, '.git'), { recursive: true }) await mkdir(join(workdir, 'pkg/deep'), { recursive: true }) - await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the Code Mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`) + await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the PTC mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`) await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n') ctx = await workspaceCodeModeHarness() const handle = await ctx.agents.create({ - sessionId: SessionId('e2e-code-mode-workspace-session'), + sessionId: SessionId('e2e-ptc-workspace-session'), meta: { cwd: workdir }, agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, }) @@ -411,7 +411,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p handle.agent.followup(createUserMessage({ content: [{ type: 'text', - text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: Code Mode workspace handshake?', + text: 'Use one run_code program to call tools.read on pkg/deep/task.txt. After it finishes, answer: PTC mode workspace handshake?', }], source: { kind: 'user' } })) await waitForIdle(ctx, handle.agent) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 7903cb6e9e..a08ef12d8a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -216,7 +216,7 @@ describe('the shipped Web composition', () => { it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) expect(ctx.agentPresets.defaultId).toBe('standard') }) @@ -350,18 +350,18 @@ describe('the shipped Web composition', () => { } }) - it('presents `code` as Code Mode without disturbing a native session beside it', async () => { + it('presents `ptc` as PTC mode without disturbing a native session beside it', async () => { const coded = await ctx.agents.create({ - sessionId: SessionId('preset-code'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'code').then(() => undefined), + sessionId: SessionId('preset-ptc'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'ptc').then(() => undefined), }) const native = await ctx.agents.create({ - sessionId: SessionId('preset-code-native'), + sessionId: SessionId('preset-ptc-native'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), }) try { // One tool reaches the MODEL: the transport. The registry's catalog for - // this agent is unchanged — a code mode collapses the presentation, not + // this agent is unchanged — PTC mode collapses the presentation, not // the capabilities — so the assembly is what carries the claim. const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) @@ -948,7 +948,7 @@ describe('a composition that configures its own preset roots', () => { ]) const listed = await rootsCtx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard', 'team-spec']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard', 'team-spec']) expect(listed.every(preset => preset.broken === undefined)).toBe(true) // The shipped root comes first: a configured directory claiming a shipped // id is shadowed, never the other way around. diff --git a/apps/cli/tests/windows-shell.spec.ts b/apps/cli/tests/windows-shell.spec.ts index f94ce582a6..5c9045e441 100644 --- a/apps/cli/tests/windows-shell.spec.ts +++ b/apps/cli/tests/windows-shell.spec.ts @@ -104,7 +104,7 @@ describe('the shipped shell composition (real bundle layers)', () => { describe('shipped agent presets gate both shell tools by platform', () => { const presetRoot = SHIPPED_PRESET_ROOT - it.each(['standard', 'code', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { + it.each(['standard', 'ptc', 'cordis'])('preset %s gates its shell tool rows by platform', (preset) => { const entries: unknown = yaml.load( readFileSync(join(presetRoot, preset, 'agent.cordis.yml'), 'utf8'), { schema: entryListSchema }, diff --git a/apps/web/tests/expected/agent-preset-authoring/created.expected.md b/apps/web/tests/expected/agent-preset-authoring/created.expected.md index d82f48d8c1..8827af14ad 100644 --- a/apps/web/tests/expected/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/created.expected.md @@ -33,8 +33,8 @@ - text: 复制 - listitem: - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - - code: code + - text: PTC 模式 内置 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 + - code: ptc - 'button "查看: PTC 模式"': - img - text: 查看 diff --git a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md index 0869bd7f3f..ed0ec29cc9 100644 --- a/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/damaged.expected.md @@ -33,8 +33,8 @@ - text: 复制 - listitem: - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - - code: code + - text: PTC 模式 内置 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 + - code: ptc - 'button "查看: PTC 模式"': - img - text: 查看 diff --git a/apps/web/tests/expected/agent-preset-authoring/section.expected.md b/apps/web/tests/expected/agent-preset-authoring/section.expected.md index c8d983cb34..ec0e49aece 100644 --- a/apps/web/tests/expected/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/expected/agent-preset-authoring/section.expected.md @@ -33,8 +33,8 @@ - text: 复制 - listitem: - 'button "设为默认: PTC 模式"': - - text: PTC 模式 内置 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 - - code: code + - text: PTC 模式 内置 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 + - code: ptc - 'button "查看: PTC 模式"': - img - text: 查看 diff --git a/apps/web/tests/expected/agent-preset-selection/menu.expected.md b/apps/web/tests/expected/agent-preset-selection/menu.expected.md index 4633f83fde..d0a80be760 100644 --- a/apps/web/tests/expected/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/menu.expected.md @@ -2,7 +2,7 @@ - menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.": - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." + - menuitem "PTC mode All Standard mode capabilities, with tools exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program." - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." - menuitem "Refusing mode Resolves, then refuses to start." diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 55dcedff13..3b5f8d00f2 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture +// Multimodal image surfaces over the BUILT client graph (the ptc-fixture // idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the product surfaces: the diff --git a/apps/web/tests/code-mode-round.e2e.ts b/apps/web/tests/ptc-round.e2e.ts similarity index 90% rename from apps/web/tests/code-mode-round.e2e.ts rename to apps/web/tests/ptc-round.e2e.ts index 819521e6be..1098f6c203 100644 --- a/apps/web/tests/code-mode-round.e2e.ts +++ b/apps/web/tests/ptc-round.e2e.ts @@ -1,4 +1,4 @@ -// Code Mode browser round trip with nested sub-calls and details selection. +// PTC mode browser round trip with nested sub-calls and details selection. // Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless // DSH_SNAPSHOT=refresh regenerates ui.expected.md. import { readFile } from 'node:fs/promises' @@ -13,15 +13,15 @@ import { } from './scaffold.ts' import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/session.jsonl', import.meta.url)) -const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/code-mode-round/ui.expected.md', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/ptc-round/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/ptc-round/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() // Elicits the successful and failed sub-rows this scenario asserts. const PROMPT = 'Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt ' + 'catching its error in the program. Return an object with both outcomes. Then reply DONE and stop.' -describe('web e2e: Code Mode round renders nested sub-calls', () => { +describe('web e2e: PTC mode round renders nested sub-calls', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -30,7 +30,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { beforeAll(async () => { scaffold = await launchWebScaffold({ - toolsMode: 'code', + toolsMode: 'ptc', compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) @@ -49,7 +49,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) it('drives the recorded prompt to a settled turn (all modes)', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-drive')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-drive')) if (MODE !== 'record') { expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) } @@ -89,7 +89,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) it.skipIf(MODE === 'record')('renders the code parent row with always-visible nested sub-rows', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-rows')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-rows')) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) // The parent run_code row wears the code variant with the model-authored // description as its summary (the presentCall contract). @@ -103,7 +103,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }, 60_000) it.skipIf(MODE === 'record')('a bash sub-row click leaves the default details panel closed', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-details')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-details')) const nest = page.locator('[data-subcalls]').first() const frame = page.locator('[style*="grid-template-columns"]').first() expect(await frame.getAttribute('data-details-collapsed')).toBe('true') @@ -113,7 +113,7 @@ describe('web e2e: Code Mode round renders nested sub-calls', () => { }) it.skipIf(MODE === 'record')('matches the expanded conversation aria golden with stable anchors', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-code-mode-aria')) + onTestFailed(() => saveFailureShot(page, 'web-e2e-ptc-aria')) const snapshot = await captureExpandedTurnProcessAria( page, '[class*="centerCol"]', diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index b664348c25..6a98024ec1 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -289,7 +289,7 @@ export interface LaunchOptions { * yml default. The code runtime row is always in the tree, so no extra * insertion is needed. */ - toolsMode?: 'native' | 'code' | 'both' + toolsMode?: 'native' | 'ptc' | 'both' /** * Insert the opt-in model-facing Cordis tool provider into the shipped tree. * Record and replay use the same tool surface, so captured request headers diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index ba28fd09ad..d5d1b0c6c8 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -590,16 +590,16 @@ describe('dsh web keyless CLI smoke', () => { } }, 120_000) - it('DSH_TOOLS_MODE=code collapses the provider wire tools to run_code with the SDK prompt section', async () => { + it('DSH_TOOLS_MODE=ptc collapses the provider wire tools to run_code with the SDK prompt section', async () => { requireDist() - const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-code-mode-')) + const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-ptc-')) - interface CodeModeProviderRequest { + interface PtcModeProviderRequest { messages?: { role?: string; content?: string }[] tools?: { function?: { name?: string } }[] } - let resolveProviderRequest!: (request: CodeModeProviderRequest) => void - const providerRequest = new Promise((resolve) => { + let resolveProviderRequest!: (request: PtcModeProviderRequest) => void + const providerRequest = new Promise((resolve) => { resolveProviderRequest = resolve }) const provider = createServer((request, response) => { @@ -607,7 +607,7 @@ describe('dsh web keyless CLI smoke', () => { request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as CodeModeProviderRequest) + resolveProviderRequest(JSON.parse(body) as PtcModeProviderRequest) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end([ 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', @@ -629,9 +629,9 @@ describe('dsh web keyless CLI smoke', () => { cwd: workspace, env: { ...process.env, - DEEPSEEK_API_KEY: 'keyless-web-code-mode', + DEEPSEEK_API_KEY: 'keyless-web-ptc', DEEPSEEK_BASE_URL: `http://127.0.0.1:${address.port}`, - DSH_TOOLS_MODE: 'code', + DSH_TOOLS_MODE: 'ptc', DSH_HOME: join(workspace, '.dsh'), DSH_AGENTS_HOME: join(workspace, '.agents'), TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'), diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts index 94607563d8..315d3baa1e 100644 --- a/apps/web/tests/trajectory-image-display.expected.e2e.ts +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -1,5 +1,5 @@ // @vitest-environment jsdom -// Trajectory image surfaces over the BUILT client graph (the code-mode-fixture +// Trajectory image surfaces over the BUILT client graph (the ptc-fixture // idiom: real bundles via AppWebEntry, keyless fixture Connection RPC). // Opens the fixture history session whose turn 73 carries an image in BOTH a // user message and an assistant message, and pins the Trajectory surfaces: diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index f261db5614..2357de9d14 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -59,7 +59,7 @@ "tests/sidebar-scrollbar.e2e.ts", "tests/rail-search-expand.e2e.ts", "tests/conversation-column-overflow.e2e.ts", - "tests/code-mode-round.e2e.ts", + "tests/ptc-round.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index b9d870136b..9c48fe3636 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 3b05dbf6a9e4a591e876bd6993522223146288fb -config-catalog.zh.md: cbcc3d1acc9664ce3c92eb7dcb3fc0d12a8c466d +config-catalog.md: cb2dfbdee786b2b6203e4e6f4f08fe8ad1d84a2f +config-catalog.zh.md: 60ccf2bf7a001caa2e0fd305476c0ef73fc8db2c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3b05dbf6a9..cb2dfbdee7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3126,13 +3126,13 @@ Requires: `systemPrompt` /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` + * Model presentation. `native` (default) sends every visible schema; `ptc` * 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` + * sends both forms. PTC mode requires 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 + * assembly when it is absent or has no renderer. Under `ptc`, native names * in `toolOrder` are invalid. */ mode?: ToolPresentationMode @@ -3147,7 +3147,7 @@ export interface Config { } /** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'code' | 'both' +export type ToolPresentationMode = 'native' | 'ptc' | 'both' ``` Source: [`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cbcc3d1acc..60ccf2bf7a 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3128,13 +3128,13 @@ export interface Config { /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` + * Model presentation. `native` (default) sends every visible schema; `ptc` * 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` + * sends both forms. PTC mode requires 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 + * assembly when it is absent or has no renderer. Under `ptc`, native names * in `toolOrder` are invalid. */ mode?: ToolPresentationMode @@ -3149,7 +3149,7 @@ export interface Config { } /** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'code' | 'both' +export type ToolPresentationMode = 'native' | 'ptc' | 'both' ``` 来源:[`packages/core/tools/src/index.ts:655`](../packages/core/tools/src/index.ts) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index 3aaabe5ef5..9d02664a90 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: 9ec025426a2304f4bda56ddb8e6f5c42e3e8f4a8 -adding-a-tool.zh.md: 2c73b40ff53b5b58507b716ff32dd1fea26148f4 +adding-a-tool.md: f6b2703f3db69a6abc04dbd0fd9893555eed0602 +adding-a-tool.zh.md: d4831e2be061c2565e88004062cbb1558491bb9d diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 9ec025426a..f6b2703f3d 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -50,7 +50,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S ## Long-running work -Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but Code Mode must never parse that prose to recover the id. +Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id. The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. @@ -58,9 +58,9 @@ The producer supplies synchronous `cancel`, non-rejecting `done` that settles af Prefer not to build deployment policy into the tool. Use `tools/pre-execute` for extensible allow/deny/ask policy (the [permission-gate example](extension-cookbook.md#a-hook-plugin-permission-gate-example)), `ctx.tools.guard()` for a final monotonic deny that later listeners cannot undo, `tools/execute` to wrap dispatch with a deadline, retry, or metrics collection, `tools/post-execute` to replace presentation content or the returned value, block the result, or attach model-facing context, and `tools/result` to observe the immutable normalized outcome. A content replacement leaves programmatic access to `value` intact; confidentiality policy blocks or replaces the value. A sandboxing implementation can also run inside the tool's executor implementation; the [`dsh-tools` README](../../packages/core/tools/README.md#extension-points) defines each extension point's inputs, order, return values, and failure behavior. -## Code Mode reaches your tool for free +## PTC mode reaches your tool for free -In [Code Mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The generated `ToolArgsMap` and `ToolOutputMap` derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real `ToolCallError`; programs can inspect only its `name`, `toolName`, and human-readable `message`, not internal error codes or a failure union. +In [PTC mode](../../packages/core/tools/README.md), every visible registered tool is available as `await tools.(args)` without extra integration. The generated `ToolArgsMap` and `ToolOutputMap` derive exact argument and canonical-return types from the same schemas, and calls re-enter the normal execution pipeline. A successful call resolves to the final canonical JSON value after policy, not to rendered Native content. A failed call rejects with the real `ToolCallError`; programs can inspect only its `name`, `toolName`, and human-readable `message`, not internal error codes or a failure union. Design `output.schema` as a useful programmatic API: return handles and fields directly, allow scalar/array/null roots when they are the honest value, and keep human explanation in `output.render`. Intermediate values are execution-local, are not persisted or prompt-truncated, and have no byte cap, so the producer's truthful acquisition bounds and process memory still matter. Only the outer `run_code` logs/result cross the configurable output cap and model-facing spill pipeline. diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 2c73b40ff5..d4831e2be0 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -50,7 +50,7 @@ export function apply(ctx: Context) { ## 长时间运行的工作 -通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 Code Mode 绝不能通过解析该文本取得 id。 +通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。 producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 @@ -60,9 +60,9 @@ producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 尽量不要把部署策略内建到工具中。使用 `tools/pre-execute` 实现可扩展的允许/拒绝/询问策略(见[权限门禁示例](extension-cookbook.zh.md#a-hook-plugin-permission-gate-example));使用 `ctx.tools.guard()` 设置最终的单调拒绝,后续监听器无法撤销;使用 `tools/execute` 为分发添加截止时间、重试或指标收集;使用 `tools/post-execute` 替换展示内容或返回值、阻止结果,或附加模型可见上下文;使用 `tools/result` 观测不可变的归一化结果而不改变它。替换内容不会阻止程序化访问 `value`;保密策略会屏蔽或替换该值。沙箱实现也可以在工具的执行器实现中运行;[`dsh-tools` README](../../packages/core/tools/README.zh.md#extension-points) 定义每个扩展点的输入、顺序、返回值和失败行为。 -## Code Mode 自动触达你的工具 +## PTC mode 自动触达你的工具 -在 [Code Mode](../../packages/core/tools/README.zh.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。生成的 `ToolArgsMap` 和 `ToolOutputMap` 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的 Native 内容。失败调用会以真正的 `ToolCallError` reject;程序只能检查其 `name`、`toolName` 和可供人阅读的 `message`,无法取得内部错误代码或失败联合。 +在 [PTC mode](../../packages/core/tools/README.zh.md) 中,每个可见的已注册工具都可通过 `await tools.(args)` 调用,无需额外集成。生成的 `ToolArgsMap` 和 `ToolOutputMap` 会根据同一组 schema 分别派生精确的参数类型与规范返回类型,调用则重新进入正常的执行流水线。成功调用会解析为策略处理后的最终规范 JSON 值,而不是渲染后的 Native 内容。失败调用会以真正的 `ToolCallError` reject;程序只能检查其 `name`、`toolName` 和可供人阅读的 `message`,无法取得内部错误代码或失败联合。 请把 `output.schema` 设计为实用的程序化 API:直接返回句柄与字段;当标量、数组或 null 确实就是结果时,允许采用相应的根类型;将面向人类的解释放入 `output.render`。中间值只存在于执行期间,不会被持久化或按提示词上限截断,也不设字节上限,因此生产方如实声明的采集边界和进程内存仍然重要。只有外层 `run_code` 日志/结果会受到可配置输出上限和面向模型的 spill 流水线约束。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 24c280ae11..b24e6b88b2 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 7b8c55837038aa421850449ebf553eb1ef47218f -extension-cookbook.zh.md: 899fcca4ad3c8977044da5a17e40283905abc752 +extension-cookbook.md: bb3f327691d1ff6199407c241f4fa77cd4b23b87 +extension-cookbook.zh.md: db8f0850bdada35cb6d33eed874c44f20d7be9d3 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 7b8c558370..bb3f327691 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -96,7 +96,7 @@ Shipped applications contribute profile layers through `packages/bundle/*/cordis Every product feature maps to a listener on a documented extension point — the microkernel claim made checkable ([microkernel Agent Note](../../.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md)). No row modifies the loop. -`system-prompt/assemble` is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active Code Mode and structured-output protocol contributions. Prefer `ctx.tools.restrict()` for tool filtering that must stay aligned across presentation, lookup, and execution. +`system-prompt/assemble` is an expert cooperative whole-assembly transform: its returned assembly is authoritative, so listener authors own preserving active PTC mode and structured-output protocol contributions. Prefer `ctx.tools.restrict()` for tool filtering that must stay aligned across presentation, lookup, and execution. | Product feature | Plugin mechanism | |---|---| diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 899fcca4ad..db8f0850bd 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -100,7 +100,7 @@ export function apply(ctx: Context) { 每个产品功能都映射到一个文档化扩展点上的监听器——微内核声明由此可验证([微内核 Agent Note](../../.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md))。没有任何一行修改循环本身。 -`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 Code Mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。 +`system-prompt/assemble` 是一个专家协作式的整体装配变换:其返回的装配结果具有权威性,因此监听器作者有责任保留活跃的 PTC mode 和结构化输出协议的贡献。对于需要在展示、查找和执行之间保持对齐的工具过滤,优先使用 `ctx.tools.restrict()`。 | 产品功能 | 插件机制 | |---|---| diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 14704caf43..c45e00ced7 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 2647c0eddacb22d1f96e1e780e4c9f1d16b4ae0d -development.zh.md: b7ac5fb4d46de68f502dbcab8eef50944055718f +development.md: 7fc6ae14266253b9e50a1a5f3e9ee6f8e6fbcde7 +development.zh.md: 79e25b489d4c7c3f425460d5bffd0c0265f5b02b diff --git a/docs/development.md b/docs/development.md index 2647c0edda..7fc6ae1426 100644 --- a/docs/development.md +++ b/docs/development.md @@ -140,10 +140,10 @@ The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment o pnpm dsh --profile headless "summarize this workspace" ``` -The Code Mode demo runs the same headless profile with code presentation enabled: +The PTC mode demo runs the same headless profile with code presentation enabled: ```sh -pnpm run demo:code-mode -- "summarize this workspace" +pnpm run demo:ptc -- "summarize this workspace" ``` ### TODO markers diff --git a/docs/development.zh.md b/docs/development.zh.md index b7ac5fb4d4..79e25b489d 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -144,10 +144,10 @@ pnpm run build pnpm dsh --profile headless "summarize this workspace" ``` -Code Mode 演示启用代码式工具展示,并运行同一个 headless profile: +PTC mode 演示启用代码式工具展示,并运行同一个 headless profile: ```sh -pnpm run demo:code-mode -- "summarize this workspace" +pnpm run demo:ptc -- "summarize this workspace" ``` ### TODO 标记 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 749e1e0695..7883982b1b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 8c30d5a945e2abb21c1e8dad5d6aa4e472c18b23 -event-producer-consumer.zh.md: 29304d9ac855a36ffc8a5e6766bedc841cf2738c +event-producer-consumer.md: 8c3a8a3f0e19f50e5a3164e2f5f4ac0a4e1046eb +event-producer-consumer.zh.md: 65b8fba3dd5b9654ffbd037dded15abe85c5885c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 8c30d5a945..8c3a8a3f0e 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -59,10 +59,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 29304d9ac8..65b8fba3dd 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -61,10 +61,10 @@ | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:207`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:163`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) | | `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:189`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:197`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | | `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:85`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` | | `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `inspector`, `modules` | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 6a61b25f17..7613f6fa12 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: 6a48b9c674375c6b5fa8b296afb7658a9d508168 -persistence-catalog.zh.md: 367b1c1a11324cea057ff03d0c456d531edc0183 +persistence-catalog.md: 2143f7881edad36a986869fcbb458ae464014a44 +persistence-catalog.zh.md: e37d6ed5fd1c7bd3730c411312fef186c89a4b20 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 6a48b9c674..2143f7881e 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -870,7 +870,7 @@ Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/ * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': CodeDispatchEventData +'tool/code-dispatch': PtcDispatchEventData ``` Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) @@ -893,7 +893,7 @@ Source: [`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': CodeDispatchStartEventData +'tool/code-dispatch-start': PtcDispatchStartEventData ``` Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 367b1c1a11..e37d6ed5fd 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -852,9 +852,9 @@ export type SessionEvent = { 来源:[`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) - + -#### `tool/code-dispatch` — log-only +#### `tool/ptc-dispatch` — log-only ```ts persistence-catalog /** @@ -872,14 +872,14 @@ export type SessionEvent = { * before returning), so its execution-enclosure relation holds by * construction. */ -'tool/code-dispatch': CodeDispatchEventData +'tool/code-dispatch': PtcDispatchEventData ``` 来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) - + -#### `tool/code-dispatch-start` — log-only +#### `tool/ptc-dispatch-start` — log-only ```ts persistence-catalog /** @@ -895,7 +895,7 @@ export type SessionEvent = { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ -'tool/code-dispatch-start': CodeDispatchStartEventData +'tool/code-dispatch-start': PtcDispatchStartEventData ``` 来源:[`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types.ts) diff --git a/docs/subsystems/code-runtime.i18n.yaml b/docs/subsystems/code-runtime.i18n.yaml index 97886685e5..228ed14b56 100644 --- a/docs/subsystems/code-runtime.i18n.yaml +++ b/docs/subsystems/code-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/code-runtime.md -code-runtime.md: ae760487eff19f6a0b91620b92007d0a86eb1589 -code-runtime.zh.md: b48e1a01b2817d9dafb25ac96673d00cf7d9ed08 +code-runtime.md: 0f633df9fc657d9d80fc04df3bc8ad6fafdddcb2 +code-runtime.zh.md: 43b78ce49575741f7ae6c4e2751b63b7562fc99a diff --git a/docs/subsystems/code-runtime.md b/docs/subsystems/code-runtime.md index ae760487ef..0f633df9fc 100644 --- a/docs/subsystems/code-runtime.md +++ b/docs/subsystems/code-runtime.md @@ -2,7 +2,7 @@ English | [中文](code-runtime.zh.md) -The code-execution seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) whose Service Definition ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread Service Provider and tool-registry Consumer are specified by the [Code Mode foundation](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) and [typed-return contract](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.md). +The code-execution seam — a [capability seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) whose Service Definition ([dsh-code-runtime](../../packages/code-runtime/code-runtime), `ctx.codeRuntime`) runs one model-written program against host-provided async bindings and reports what it printed and returned. Code execution is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). Backends differ by execution substrate and source language, both readonly descriptors on the service; the worker-thread Service Provider and tool-registry Consumer are specified by the [PTC mode foundation](../../.agents/notes/implemented/feature/2026-06-15-ptc.md) and [typed-return contract](../../.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md). Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -61,7 +61,7 @@ interface CodeRunResult { ## Bindings: host functions as program globals -Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A namespace may declare a program-visible error class without making the runtime know the consumer's names: the runtime injects the real constructor and turns rejected calls into its instances. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): +Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the PTC mode consumer passes one: `tools`). Arguments and resolutions must be lossless JSON and cross without a seam-level byte cap; the runtime may bridge them through structured clone. A namespace may declare a program-visible error class without making the runtime know the consumer's names: the runtime injects the real constructor and turns rejected calls into its instances. A runtime also treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): ```ts type-equiv /** @@ -69,7 +69,7 @@ Each `CodeBindingNamespace` becomes one global object of async callables inside * injects a real error constructor under `name`; rejected member calls become * its instances and expose the exact member name through * `memberNameProperty`. Both strings are runtime data rather than knowledge - * of a particular consumer such as Code Mode. + * of a particular consumer such as PTC mode. */ interface CodeBindingErrorClass { /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ diff --git a/docs/subsystems/code-runtime.zh.md b/docs/subsystems/code-runtime.zh.md index b48e1a01b2..43b78ce495 100644 --- a/docs/subsystems/code-runtime.zh.md +++ b/docs/subsystems/code-runtime.zh.md @@ -2,7 +2,7 @@ [English](code-runtime.md) | 中文 -代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md):其 Service Definition([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)使用宿主提供的异步绑定运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread Service Provider 与工具注册表 Consumer 的约定见 [Code Mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md) 和[类型化返回约定](../../.agents/notes/implemented/feature/2026-07-20-code-mode-typed-tool-returns.zh.md)。 +代码执行 seam 是一个[能力 seam](../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md):其 Service Definition([dsh-code-runtime](../../packages/code-runtime/code-runtime),`ctx.codeRuntime`)使用宿主提供的异步绑定运行一段模型编写的程序,并报告其打印内容与返回值。代码执行是**一项可选能力**,不属于 agent loop(智能体循环)主干,因此其词汇定义在此而非 [core.md](core.zh.md) 中。各后端的执行基底与源语言不同,这两项均为服务上的只读描述符;worker-thread Service Provider 与工具注册表 Consumer 的约定见 [PTC mode 基础设计](../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 和[类型化返回约定](../../.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md)。 源码:[`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code-runtime/code-runtime/src/types.ts) @@ -61,7 +61,7 @@ interface CodeRunResult { ## 绑定:宿主函数作为程序全局变量 -每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(Code Mode Consumer 传入一个:`tools`)。参数与返回值必须是无损 JSON,且跨越边界时不受 seam 层字节上限约束;运行时可以通过结构化克隆桥接它们。命名空间可以声明程序可见的错误类,而无需让运行时知道 Consumer 的名称:运行时会注入真实构造函数,并将被拒绝的调用转为该类的实例。运行时也将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): +每个 `CodeBindingNamespace` 在程序内成为一个由异步可调用函数组成的全局对象(PTC mode Consumer 传入一个:`tools`)。参数与返回值必须是无损 JSON,且跨越边界时不受 seam 层字节上限约束;运行时可以通过结构化克隆桥接它们。命名空间可以声明程序可见的错误类,而无需让运行时知道 Consumer 的名称:运行时会注入真实构造函数,并将被拒绝的调用转为该类的实例。运行时也将绑定名视为不可信输入(`__proto__` 是普通自有属性,绝不会发生原型碰撞): ```ts type-equiv /** @@ -69,7 +69,7 @@ interface CodeRunResult { * injects a real error constructor under `name`; rejected member calls become * its instances and expose the exact member name through * `memberNameProperty`. Both strings are runtime data rather than knowledge - * of a particular consumer such as Code Mode. + * of a particular consumer such as PTC mode. */ interface CodeBindingErrorClass { /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 569d755993..acb9055376 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: 16142c2f7d98cf1037b034d2836c742b62f26594 -tool-catalog.zh.md: 533caacc7a923b5ea36f527292f420b610f1c488 +tool-catalog.md: 91ff093e79cc05a2c08b5aa1130378440cd963f3 +tool-catalog.zh.md: 48627ed04fd264f9ea28842a94596667b9629ec5 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 16142c2f7d..91ff093e79 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -16,7 +16,7 @@ This table connects model-visible tool names to the plugin package and service s | Tool package | Model-visible names | Requires | Writes / affects | Shipped aliases | Deployment note | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. | @@ -144,9 +144,9 @@ Execute a TypeScript program against the available tools. Takes two required arg } ``` -Source: [`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) +Source: [`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) -Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. +Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 533caacc7a..48627ed04f 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -20,7 +20,7 @@ | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: code`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 Code Mode Agent Note)。在 `code` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `code` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | @@ -148,9 +148,9 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 } ``` -来源:[`packages/core/tools/src/code-mode.ts`](../packages/core/tools/src/code-mode.ts) +来源:[`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) -在 `mode: code`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 Code Mode Agent Note)。在 `code` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 +在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `code` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index bd8de3ba60..62ee38c9dd 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-execution-pipeline.md -tool-execution-pipeline.md: d04d2e4e5093fee92f8921f0eb0112c960a81bb8 -tool-execution-pipeline.zh.md: 15627023d3be6ac2b3aae70c2ef01ef9f1077d3e +tool-execution-pipeline.md: a799c68a60f6782ef3bb79c52f89cbc78d762ab3 +tool-execution-pipeline.zh.md: 04a242f47abe55ce43ac536cc5261a3b075d8b4f diff --git a/docs/tool-execution-pipeline.md b/docs/tool-execution-pipeline.md index d04d2e4e50..a799c68a60 100644 --- a/docs/tool-execution-pipeline.md +++ b/docs/tool-execution-pipeline.md @@ -57,6 +57,6 @@ flowchart TD allResults --> context ``` -Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. +Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition's snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency. Maintenance mode: curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs. diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 15627023d3..04a242f47a 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -59,6 +59,6 @@ flowchart TD allResults --> context ``` -文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。Code Mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/code-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 +文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。PTC mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/ptc-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 维护模式:英文源文件包含人工维护的 Mermaid 流程图,并由生成器写出;本中文文件作为经评审对侧通过双语配对维护。确切的工具 schema 与事件签名位于生成的目录中。 diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 99fdfe2139..0c7c5bbc82 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/tool.md -tool.md: 24a82d277e626ba78760a0afb1c41e2a157eb8ee -tool.zh.md: a07bef588f5a18093cf2eb1971b855bf40d1d0b6 +tool.md: aeed5cf0e742cdde33bdcb86bfac4de471ae2597 +tool.zh.md: 25538e42b7777fe3b05b361379a8bce70d6ec5f5 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index 24a82d277e..aeed5cf0e7 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -48,5 +48,5 @@ Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The mod ## Next steps - [Plugin configuration](./config.md) — make the greeting configurable. -- [Tool authoring reference](../../../cookbook/adding-a-tool.md) — look up nested schemas, canonical values, background work, policy hooks, Code Mode, and UI cards. +- [Tool authoring reference](../../../cookbook/adding-a-tool.md) — look up nested schemas, canonical values, background work, policy hooks, PTC mode, and UI cards. - [Capability layering](../practice/index.md) — split a replaceable capability into Service Definition, Service Provider, and Consumer packages. diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index a07bef588f..25538e42b7 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -48,5 +48,5 @@ pnpm dsh web --patch ./scratch-plugin/cordis.yml ## 下一步 - [插件配置](./config.zh.md) — 让问候语可配置。 -- [工具编写参考](../../../cookbook/adding-a-tool.zh.md) — 查阅嵌套 schema、规范值、后台工作、策略钩子、Code Mode 和 UI 卡片。 +- [工具编写参考](../../../cookbook/adding-a-tool.zh.md) — 查阅嵌套 schema、规范值、后台工作、策略钩子、PTC mode 和 UI 卡片。 - [能力分层](../practice/index.zh.md) — 将可替换能力拆分为 Service Definition、Service Provider 和 Consumer 三类包。 diff --git a/package.json b/package.json index e6d9253314..fac0df28e8 100644 --- a/package.json +++ b/package.json @@ -148,7 +148,7 @@ "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:code-mode": "node scripts/demo-code-mode.mjs", + "demo:ptc": "node scripts/demo-ptc.mjs", "demo:inspector": "node --import tsx/esm apps/cli/src/bin.ts web --patch ./packages/experimental/inspector/cordis.source.patch.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 80cc07be60..d1246b79ba 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -11,11 +11,11 @@ - id: tools config: - # Keep the same temporary process-wide Code Mode opt-in as the Web surface. + # Keep the same temporary process-wide PTC mode opt-in as the Web surface. mode: !!js process.env.DSH_TOOLS_MODE - insert: - # Code Mode is a core execution capability, not a Web component. + # PTC mode is a core execution capability, not a Web component. - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker-thread' diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index f63af2885a..9ee698044e 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -30,8 +30,8 @@ - id: tools config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh - # process into Code Mode while per-session tool-presentation selection is being + # TEMPORARY workaround: DSH_TOOLS_MODE (native|ptc|both) opts a whole dsh + # process into PTC mode while per-session tool-presentation selection is being # designed; unset keeps the schema default (native). Remove the env seam # once the web UI owns the choice per session. mode: !!js process.env.DSH_TOOLS_MODE diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 50d7650703..b8e9f8b6e2 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -5,7 +5,7 @@ export type AgentPresetSettingsKey = | 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint' | 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view' | 'presetStandardName' | 'presetStandardDescription' - | 'presetCodeName' | 'presetCodeDescription' + | 'presetPtcName' | 'presetPtcDescription' | 'presetMinimalName' | 'presetMinimalDescription' | 'presetCordisName' | 'presetCordisDescription' | 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf' @@ -37,9 +37,9 @@ export const en: Record = { presetStandardName: 'Standard mode', presetStandardDescription: 'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.', - presetCodeName: 'PTC mode', - presetCodeDescription: - 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', + presetPtcName: 'PTC mode', + presetPtcDescription: + 'All Standard mode capabilities, with tools exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: 'Two-tool coding agent with persistent bash and str_replace_editor.', @@ -101,8 +101,8 @@ export const zh: Record = { view: '查看', presetStandardName: '标准模式', presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。', - presetCodeName: 'PTC 模式', - presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', + presetPtcName: 'PTC 模式', + presetPtcDescription: '具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', presetCordisName: '创造模式', @@ -170,7 +170,7 @@ interface PresetLocaleKeys { const BUILT_IN_PRESET_KEYS: Readonly>> = { standard: { name: 'presetStandardName', description: 'presetStandardDescription' }, - code: { name: 'presetCodeName', description: 'presetCodeDescription' }, + ptc: { name: 'presetPtcName', description: 'presetPtcDescription' }, minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' }, cordis: { name: 'presetCordisName', description: 'presetCordisDescription' }, } diff --git a/packages/client/ui-agent-preset/tests/locales.client.spec.ts b/packages/client/ui-agent-preset/tests/locales.client.spec.ts index 02623e7d7b..1d8b40cc67 100644 --- a/packages/client/ui-agent-preset/tests/locales.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/locales.client.spec.ts @@ -8,7 +8,7 @@ const translate = (bundle: typeof en) => (key: keyof typeof en): string => bundl describe('preset display copy', () => { it.each([ ['standard', 'presetStandardName', 'presetStandardDescription'], - ['code', 'presetCodeName', 'presetCodeDescription'], + ['ptc', 'presetPtcName', 'presetPtcDescription'], ['minimal', 'presetMinimalName', 'presetMinimalDescription'], ['cordis', 'presetCordisName', 'presetCordisDescription'], ] as const)('localizes the shipped %s preset in English and Chinese', (id, nameKey, descriptionKey) => { diff --git a/packages/code-runtime/code-runtime-worker-thread/tests/bootstrap.spec.ts b/packages/code-runtime/code-runtime-worker-thread/tests/bootstrap.spec.ts index a2aac6d9c2..a7966d7417 100644 --- a/packages/code-runtime/code-runtime-worker-thread/tests/bootstrap.spec.ts +++ b/packages/code-runtime/code-runtime-worker-thread/tests/bootstrap.spec.ts @@ -62,7 +62,7 @@ async function rejectionOf(promise: Promise): Promise { const BOOT = { maxOutputBytes: 65_536 } const TOOL_ERROR_CLASS = { name: 'ToolCallError', memberNameProperty: 'toolName' } as const -/** One worker declaration for the Code Mode tools namespace. */ +/** One worker declaration for the PTC mode tools namespace. */ function toolNamespace(names: string[]) { return { global: 'tools', names, errorClass: TOOL_ERROR_CLASS } } diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index a204b113ac..6a5adda0be 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -25,7 +25,7 @@ export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | * injects a real error constructor under `name`; rejected member calls become * its instances and expose the exact member name through * `memberNameProperty`. Both strings are runtime data rather than knowledge - * of a particular consumer such as Code Mode. + * of a particular consumer such as PTC mode. */ export interface CodeBindingErrorClass { /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index f65454949c..354ed920f8 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -690,7 +690,7 @@ describe('tool-call scheduler: failure quiescence', () => { }) }) -describe('code-mode native-tool denial through the agent loop', () => { +describe('PTC 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' @@ -705,7 +705,7 @@ describe('code-mode native-tool denial through the agent loop', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt, { persona: '' }) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) // 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) diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 382c0caf61..ed795edc23 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", - "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", + "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index c4105f1ec2..5db95894fc 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -7,13 +7,13 @@ * consumers, so it cannot move into a preset. What a preset CAN own is the * presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE, * which is the preset's standing mount, so the declaration covers every agent - * joined to that preset and a Code Mode preset runs beside native ones in one + * joined to that preset and a PTC mode preset runs beside native ones in one * process. One row per composition, not one per session. * - * A code mode needs a TypeScript code runtime, which is a host-plane service + * A PTC mode needs a TypeScript code runtime, which is a host-plane service * ([`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker/README.md)). * This row therefore waits for it rather than assuming it: a preset selecting - * Code Mode against a deployment that composes no runtime fails at mount, named + * PTC mode against a deployment that composes no runtime fails at mount, named * in the preset's own activation audit, instead of at the first prompt. * @module @deepseek-ai/dsh-agent-tool-presentation */ @@ -48,7 +48,7 @@ export interface Config { /** Runtime schema. */ export const Config: z = z.object({ - mode: z.union(['native', 'code', 'both'] as const).required(), + mode: z.union(['native', 'ptc', 'both'] as const).required(), }) /** diff --git a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts index 13505fbbb4..3110036e26 100644 --- a/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts +++ b/packages/core/agent-tool-presentation/tests/agent-tool-presentation.spec.ts @@ -63,9 +63,9 @@ describe('the tool-presentation row', () => { expect(inject).toEqual(['tools']) }) - it('gives its own agent Code Mode and leaves the rest native', async () => { + it('gives its own agent PTC mode and leaves the rest native', async () => { const ctx = await host() - const coded = await mount(ctx, { mode: 'code' }, 'coded') + const coded = await mount(ctx, { mode: 'ptc' }, 'coded') const plain = await mount(ctx, { mode: 'native' }, 'plain') const codedAssembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) @@ -87,7 +87,7 @@ describe('the tool-presentation row', () => { it('restores the deployment default when the agent unloads', async () => { const ctx = await host() - const { agent, row } = await mount(ctx, { mode: 'code' }) + const { agent, row } = await mount(ctx, { mode: 'ptc' }) await row.dispose() @@ -101,7 +101,7 @@ describe('the tool-presentation row', () => { it('waits for a code runtime the deployment does not compose', async () => { const ctx = await host({ runtime: false }) - const { agent, row } = await mount(ctx, { mode: 'code' }) + const { agent, row } = await mount(ctx, { mode: 'ptc' }) // Pending, not applied: `dsh-agent-presets` rejects a mount holding a row // that never reached a usable state, naming this id — so the preset fails @@ -113,7 +113,7 @@ describe('the tool-presentation row', () => { it('applies once the runtime arrives', async () => { const ctx = await host({ runtime: false }) - const { agent } = await mount(ctx, { mode: 'code' }) + const { agent } = await mount(ctx, { mode: 'ptc' }) await ctx.plugin(StubRuntime) diff --git a/packages/core/scope/src/scoped-events.generated.ts b/packages/core/scope/src/scoped-events.generated.ts index da93075512..e7f24ca4cb 100644 --- a/packages/core/scope/src/scoped-events.generated.ts +++ b/packages/core/scope/src/scoped-events.generated.ts @@ -29,10 +29,10 @@ const scopedSubjectResolvers: Readonly (args[1] as Record)['scope'], - 'tools/code-dispatch-log': args => (args[0] as Record)['agent'], 'tools/execute': args => (args[0] as Record)['agent'], 'tools/post-execute': args => (args[0] as Record)['agent'], 'tools/pre-execute': args => (args[0] as Record)['agent'], + 'tools/ptc-dispatch-log': args => (args[0] as Record)['agent'], 'tools/result': args => (args[0] as Record)['agent'], 'user-questions/request': args => (args[0] as Record)['agent'], }) diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 96c1488ea2..2adc3d787b 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -74,7 +74,7 @@ describe('scoped-dispatch invariants', () => { ['approval/request', [{ agent, toolName: 'echo' }, () => Promise.resolve('unavailable')]], ['goal/changed', [{ agent, change: { operation: 'create', ref: { id: 'goal-a', revision: 1 } } }]], ['system-prompt/assemble', [[], { scope: agent }]], - ['tools/code-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], + ['tools/ptc-dispatch-log', [{ exec: { callId: 'c', name: 't', arguments: {} }, agent, subCallId: 'c:code:1', name: 't', isError: false, content: [] }, () => Promise.resolve([])]], ['tools/execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ content: [], isError: false })]], ['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]], ['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]], diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index c4b5f81ad5..f0f0715971 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -134,7 +134,7 @@ export const FIRST_PARTY_SECTION_ORDER = { DEPLOYMENT_PERSONA: 0, PLAN_POLICY: 500, TEAM_POLICY: 600, - CODE_ONLY: 800, + PTC_ONLY: 800, FILE_REFERENCE: 900, TOOL_BASH: 1000, TOOL_PWSH: 1010, diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 8cabf75d10..f0b855cb8e 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -21,8 +21,8 @@ import type {} from '@deepseek-ai/dsh-user-approval' import type { ToolCallView, ToolResultView } from './presentation.ts' import { assertSupportedJsonSchema, validateJsonSchemaValue } from './json-schema.ts' import type { JsonSchemaNode } from './json-schema.ts' -import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './code-mode.ts' -import type { CodeSdkLanguage } from './code-mode.ts' +import { createRunCodeTool, RUN_CODE_NAME, SDK_SECTION_ORDER } from './ptc.ts' +import type { CodeSdkLanguage } from './ptc.ts' import { renderToolsSdk } from './ts-types.ts' import type { ToolSdkSchema } from './ts-types.ts' import { renderToolsSdkPy } from './py-types.ts' @@ -33,7 +33,7 @@ import { renderToolsSdkPy } from './py-types.ts' * section under a non-native mode; a runtime whose language is not a key * fails the assembly loudly (same idiom as `toolOrder` violations). Adding a * new backend language is three parallel edits — a {@link CodeSdkLanguage} - * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `code-mode.ts` for + * member, an entry here, and a `RUN_CODE_FLAVORS` entry in `ptc.ts` for * its `run_code` schema strings — plus the renderer function this table points * at. The `satisfies` clause pins this table's key set to that union, which * the flavor table is checked against too, so any of the three left out is a @@ -44,18 +44,18 @@ import { renderToolsSdkPy } from './py-types.ts' * {@link Config.mode} JSDoc. */ /** - * Prompt order of the `code` collapse statement: after the persona and before + * Prompt order of the `ptc` collapse statement: after the persona and before * per-tool guidance, so the model reads which tools it may call before it * reads what each one is for. */ -const COLLAPSE_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.CODE_ONLY +const COLLAPSE_SECTION_ORDER = FIRST_PARTY_SECTION_ORDER.PTC_ONLY /** - * The model-facing statement of the `code` collapse. Names the consequence + * The model-facing statement of the `ptc` 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 PTC_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, @@ -99,9 +99,9 @@ export { } from './json-schema.ts' export type { JsonValue } from '@deepseek-ai/dsh-session' -export type { CodeDispatchEventData, CodeDispatchStartEventData } from './types.ts' +export type { PtcDispatchEventData, PtcDispatchStartEventData } from './types.ts' -export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts' +export { CodeRunFailedError, RUN_CODE_NAME } from './ptc.ts' export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts' export { jsonSchemaToPy, renderToolsSdkPy } from './py-types.ts' export { defineContentToolFixture, type ContentToolFixtureOptions } from './testing.ts' @@ -186,7 +186,7 @@ declare module '@deepseek-ai/cordis' { * @param dispatch - the parent execution, sub-call identity, and the settled content to log. * @mode waterfall */ - 'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise + 'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise /** * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. @@ -325,11 +325,11 @@ export interface ToolExecutionInput { /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent /** - * 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 + * Opaque token of the enclosing transport execution, when one exists. PTC + * 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 + * model-direct call: under `mode: 'ptc'`, 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 ToolRuntime.execute}. */ @@ -348,14 +348,14 @@ export type ToolExecutionMode = /** * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the * `tool/code-dispatch` event's copy changes. */ -export interface CodeDispatchLog { +export interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ @@ -649,18 +649,18 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined { } /** How the registry presents its tools to the model (see {@link Config.mode}). */ -export type ToolPresentationMode = 'native' | 'code' | 'both' +export type ToolPresentationMode = 'native' | 'ptc' | 'both' /** Plugin config: how the registered tools are presented to the model. */ export interface Config { /** - * Model presentation. `native` (default) sends every visible schema; `code` + * Model presentation. `native` (default) sends every visible schema; `ptc` * 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` + * sends both forms. PTC mode requires 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 + * assembly when it is absent or has no renderer. Under `ptc`, native names * in `toolOrder` are invalid. */ mode?: ToolPresentationMode @@ -676,7 +676,7 @@ export interface Config { /** * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * scoped registrations or the reserved PTC mode transport. */ export interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -789,7 +789,7 @@ export class ToolRuntime extends Service { static inject = ['systemPrompt'] static Config: z = z.object({ - mode: z.union(['native', 'code', 'both'] as const).default('native'), + mode: z.union(['native', 'ptc', 'both'] as const).default('native'), maxParallelSubCalls: z.natural().min(1).default(10), }) @@ -822,7 +822,7 @@ export class ToolRuntime extends Service { * a code mode is no longer known when the service is constructed, and the * transport is stateless beyond its closures over `this`. */ - private codeTransport: ToolDefinition | undefined + private ptcTransport: ToolDefinition | undefined constructor(ctx: Context, config: Config = {}) { super(ctx, 'tools') @@ -854,20 +854,20 @@ export class ToolRuntime extends Service { */ private collapseSection(): { name: string; order: number; text: (context: { scope?: ScopeKey }) => string } { return { - name: 'tools:code-only', + name: 'tools:ptc-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 : '', + text: context => this.modeFor(context.scope) === 'ptc' ? PTC_ONLY_INSTRUCTION : '', } } /** - * The generated-SDK prompt section, registered globally by a code-mode + * The generated-SDK prompt section, registered globally by a PTC mode * deployment and per scope by {@link presentAs}. * * The body regenerates from the CALLING scope, and renders empty for an - * agent presenting natively — an agent that opted out under a code-mode + * agent presenting natively — an agent that opted out under a PTC mode * deployment still sees the global registration, and an empty section is * dropped from the rendered prompt. * @returns the section registration. @@ -920,7 +920,7 @@ export class ToolRuntime extends Service { * @returns the shared transport definition. */ private requireCodeTransport(): ToolDefinition { - this.codeTransport ??= createRunCodeTool(this, { + this.ptcTransport ??= createRunCodeTool(this, { requireRuntime: () => this.requireCodeRuntime(this.defaultMode), // The language-aware description/parameters getters read the runtime // without demanding one, so a native-default process can still project @@ -929,7 +929,7 @@ export class ToolRuntime extends Service { maxParallel: this.maxParallelSubCalls, shapeDispatchLog: dispatch => this.shapeDispatchLog(dispatch), }) - return this.codeTransport + return this.ptcTransport } /** @@ -938,7 +938,7 @@ export class ToolRuntime extends Service { * declaration covers every agent joined under it. * * Scoped only, and one declaration per scope: this is how an agent preset - * composes Code Mode agents beside native ones in the same process, and a + * composes PTC mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. @@ -961,7 +961,7 @@ export class ToolRuntime extends Service { { label: 'tools.presentAs()' }, ) // 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 + // mode is. Under a deployment that already defaults to PTC 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') { @@ -991,7 +991,7 @@ export class ToolRuntime extends Service { // language with no SDK renderer. this.requireCodeRuntime(mode) const schemas = [...view.visible.values()].map(definition => this.schemaOf(definition, false)) - if (mode === 'code') { + if (mode === 'ptc') { return { schemas: schemas.filter(schema => schema.name === RUN_CODE_NAME), knownNames: [RUN_CODE_NAME], @@ -1013,7 +1013,7 @@ export class ToolRuntime extends Service { * language between them would hand a program written against one SDK to the * other. Binding it is deferred until a second backend ships (the first * point it is testable); rationale in the - * [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md). + * [language-dispatch note](../../../../.agents/notes/implemented/feature/2026-07-31-ptc-language-dispatch.md). */ private requireCodeRuntime(mode: ToolPresentationMode): CodeRuntime { const runtime = this.ctx.get('codeRuntime') @@ -1051,7 +1051,7 @@ export class ToolRuntime extends Service { // so a name free to take under the deployment default would become a // collision the moment a preset mounted. if (name === RUN_CODE_NAME) { - throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the Code Mode presentation transport and cannot be registered or shadowed`) + throw new Error(`tool name "${RUN_CODE_NAME}" is reserved for the PTC mode presentation transport and cannot be registered or shadowed`) } return this.layers.effect( this.ctx, @@ -1082,7 +1082,7 @@ export class ToolRuntime extends Service { ...deny !== undefined ? { deny: new Set(deny) } : {}, } if ([...allow ?? [], ...deny ?? []].includes(RUN_CODE_NAME)) { - throw new Error(`tools.restrict() cannot name reserved Code Mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) + throw new Error(`tools.restrict() cannot name reserved PTC mode presentation transport "${RUN_CODE_NAME}"; restrict end-capability tools instead`) } const known = this.view(scope).restrictableNames const unknown = [...allow ?? [], ...deny ?? []].filter(name => !known.has(name)) @@ -1234,7 +1234,7 @@ export class ToolRuntime extends Service { return [...this.view(scope).visible.values()].map(definition => this.schemaOf(definition, true)) } - /** Project visible callable tools onto the generated Code Mode SDK contract. */ + /** Project visible callable tools onto the generated PTC mode SDK contract. */ private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] { return [...this.view(scope).visible.values()] .filter(definition => definition.name !== RUN_CODE_NAME) @@ -1284,7 +1284,7 @@ export class ToolRuntime extends Service { } /** - * Run the `tools/code-dispatch-log` waterfall over one settled sub-dispatch + * Run the `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch * and return the content the bridge should log on `tool/code-dispatch`. * Contained: when a listener throws, the method logs the original settled * content; that failure must not fail the dispatch or omit the settle event. Private: @@ -1292,20 +1292,20 @@ export class ToolRuntime extends Service { * receives it as a capability parameter (the `requireRuntime` idiom) — the * waterfall, not this invoker, is the public extension point. */ - private async shapeDispatchLog(dispatch: CodeDispatchLog): Promise { + private async shapeDispatchLog(dispatch: PtcDispatchLog): Promise { try { return await this.ctx.waterfall( - scopeTarget(this, dispatch.agent), 'tools/code-dispatch-log', dispatch, + scopeTarget(this, dispatch.agent), 'tools/ptc-dispatch-log', dispatch, () => Promise.resolve(dispatch.content), ) } catch (error: unknown) { - this.ctx.logger.warn(`tools: code-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) + this.ctx.logger.warn(`tools: ptc-dispatch-log listener failed for ${dispatch.name}: ${errorMessage(error)}; logging the original settled content`) return dispatch.content } } /** - * Whether the `code` mode collapse denies a model-direct call: only the + * Whether the `ptc` 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 @@ -1321,7 +1321,7 @@ export class ToolRuntime extends Service { * @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 + return !nested && this.modeFor(scope) === 'ptc' && name !== RUN_CODE_NAME } /** diff --git a/packages/core/tools/src/json-schema.ts b/packages/core/tools/src/json-schema.ts index 9191bcfbfa..701652ceb8 100644 --- a/packages/core/tools/src/json-schema.ts +++ b/packages/core/tools/src/json-schema.ts @@ -1,5 +1,5 @@ /** - * Enforced JSON Schema subset shared by tool outputs, generated Code Mode + * Enforced JSON Schema subset shared by tool outputs, generated PTC mode * types, subagents, and workflows. The subset accepts any JSON root, an * annotation-only schema for unconstrained JSON, one scalar `type`, object * `properties`/`required`/boolean `additionalProperties`, array `items`, diff --git a/packages/core/tools/src/code-mode.ts b/packages/core/tools/src/ptc.ts similarity index 98% rename from packages/core/tools/src/code-mode.ts rename to packages/core/tools/src/ptc.ts index 0d97797154..bd60b9f97b 100644 --- a/packages/core/tools/src/code-mode.ts +++ b/packages/core/tools/src/ptc.ts @@ -1,9 +1,9 @@ /** - * Code Mode `run_code` transport. Programs call the registry's agent-visible + * PTC mode `run_code` transport. Programs call the registry's agent-visible * tools through nested executions scheduled under the native concurrency * contract; each sub-dispatch is logged for reconstruction, while only the * outer curated result enters model history. - * @module @deepseek-ai/dsh-tools/src/code-mode + * @module @deepseek-ai/dsh-tools/src/ptc */ import { ToolCallId, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' @@ -14,10 +14,10 @@ import type { JsonValue } from '@deepseek-ai/dsh-session' import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import { defineTool, parameterSchemaSpecToJsonSchema } from './schema.ts' import { TOOL_RUNTIME_SCHEDULER } from './index.ts' -import type { CodeDispatchLog, ToolDefinition, ToolExecutionResult, ToolRuntime, ToolRunContext } from './index.ts' +import type { PtcDispatchLog, ToolDefinition, ToolExecutionResult, ToolRuntime, ToolRunContext } from './index.ts' import type {} from './types.ts' -/** The model-facing name of the Code Mode tool. */ +/** The model-facing name of the PTC mode tool. */ export const RUN_CODE_NAME = 'run_code' /** The `tools:sdk` section order, after per-tool guidance sections. */ @@ -72,7 +72,7 @@ const PYTHON_FLAVOR: RunCodeFlavor = { } /** - * The languages Code Mode ships a presentation for. Both per-language tables — + * The languages PTC mode ships a presentation for. Both per-language tables — * {@link RUN_CODE_FLAVORS} here and `SDK_RENDERERS` in {@link ./index.ts} — are * checked against this union with `satisfies`, so a language added to one and * not the other fails `typecheck` instead of waiting for a runtime that reports @@ -259,7 +259,7 @@ function renderValue(value: JsonValue): string { return typeof value === 'string' ? value : renderJsonValue(value) } -/** Canonical value returned by the outer Code Mode transport. */ +/** Canonical value returned by the outer PTC mode transport. */ type RunCodeOutput = { logs: string[]; result?: JsonValue } /** @@ -279,8 +279,8 @@ export interface RunCodeBridgeOptions { peekRuntime: () => CodeRuntime | undefined /** The run's overlap cap for parallel-classified sub-calls (the registry passes its validated `maxParallelSubCalls`). */ maxParallel: number - /** Runs the contained `tools/code-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ - shapeDispatchLog: (dispatch: CodeDispatchLog) => Promise + /** Runs the contained `tools/ptc-dispatch-log` waterfall over one settled sub-dispatch (the registry's private invoker). */ + shapeDispatchLog: (dispatch: PtcDispatchLog) => Promise } /** diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index bb03ed64ee..74669a9739 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -1,11 +1,11 @@ /** - * Code Mode codegen — Python flavor. The pure projection from registered tool schemas to the + * PTC mode codegen — Python flavor. The pure projection from registered tool schemas to the * Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of * {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry * store, keyed by the loaded {@link @deepseek-ai/dsh-code-runtime#CodeRuntime.language | code * runtime's language}. * - * Under `mode: 'code'` the native tool schemas are omitted from the request, so this generated + * Under `mode: 'ptc'` the native tool schemas are omitted from the request, so this generated * SDK is the model's ONLY source for each tool's argument names, required fields, types, * descriptions, and canonical output shapes; under `mode: 'both'` the native schemas ship * alongside it and it is one of two. Object-shaped arguments and outputs therefore render as one @@ -32,7 +32,7 @@ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u * Python identifiers are not ASCII: `路径` is as legal a field name as `path`, * and rejecting it would degrade the whole enclosing object, dropping every * field's name, requiredness, and type — information whose only source under - * `mode: 'code'` is this generated text. + * `mode: 'ptc'` is this generated text. * * NFKC stability is a second and separate condition, because CPython * normalizes identifiers at compile time while JSON keys are compared as @@ -163,7 +163,7 @@ interface RenderState { * (`SyntaxError: source code string cannot contain null bytes`), whether it * sits in a docstring or in a comment, so one such byte anywhere in a schema * description would make the whole generated SDK unparseable — under - * `mode: 'code'`, the model's only declaration of the tools. The rest are + * `mode: 'ptc'`, the model's only declaration of the tools. The rest are * legal but invisible; escaping them with the same rule keeps the emitted text * readable and the treatment uniform. * @@ -236,7 +236,7 @@ function describe(schema: object): string | undefined { * Backslashes are doubled first, every quote is escaped, and a trailing * backslash cannot survive: a description ending in `"` or an odd backslash * would otherwise merge with (or escape) the closing triple quote and make - * the generated block — Code Mode's only SDK — syntactically invalid Python. + * the generated block — PTC mode's only SDK — syntactically invalid Python. */ function docLines(description: unknown, indent: number): string[] { const collapsed = describe({ description }) @@ -579,7 +579,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } // TypedDict syntax cannot express openness, so an open object states it - // in-band: the annotation is advisory either way, and `mode: 'code'` + // in-band: the annotation is advisory either way, and `mode: 'ptc'` // omits the native schemas, making this line the model's only signal // that extra keys are accepted. if (node.additionalProperties !== false) { @@ -778,7 +778,7 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { // of that method's body. Emitted before the `async def` it would instead // become the `Tools` class docstring (for the first tool) or a dead // expression (for every later one), leaving every method undocumented — - // and under `mode: 'code'` this SDK is the model's only description of + // and under `mode: 'ptc'` this SDK is the model's only description of // what a tool does. A docstring is a complete body, so the `...` stub is // only for the description-less case. const doc = docLines(schema.description, 2) diff --git a/packages/core/tools/src/ts-types.ts b/packages/core/tools/src/ts-types.ts index a5d36a5ce3..d92c987817 100644 --- a/packages/core/tools/src/ts-types.ts +++ b/packages/core/tools/src/ts-types.ts @@ -1,5 +1,5 @@ /** - * Code Mode codegen: the pure projection from registered tool schemas to the TypeScript SDK + * PTC mode codegen: the pure projection from registered tool schemas to the TypeScript SDK * text the model programs against (the `tools:sdk` prompt section). Sibling of * `json-schema.ts` — `schemas()` (native function calling) and this module (the generated * `declare const tools` API) are two projections of the same store. @@ -9,7 +9,7 @@ import type { ToolSchema } from '@deepseek-ai/dsh-llm' import { assertSupportedJsonSchema } from './json-schema.ts' import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts' -/** Internal Code Mode projection: the model-facing schema plus the canonical output schema. */ +/** Internal PTC mode projection: the model-facing schema plus the canonical output schema. */ export interface ToolSdkSchema extends ToolSchema { /** Validated canonical value returned by the tool binding. */ output: JsonSchemaNode @@ -246,7 +246,7 @@ export function jsonSchemaToTs(schema: unknown, indent = 0): string { } } -/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */ +/** The fixed model-facing usage contract rendered above the declarations (see the PTC mode Agent Note's "What the model sees"). */ const SDK_INSTRUCTIONS = `## Writing code for run_code \`run_code\` takes two required arguments: \`code\` — the body of an async TypeScript function (erasable syntax only — no \`enum\` or namespaces; type annotations are advisory, the code runs type-stripped) — and \`description\`, a short summary of what the program does. The declarations below are SDK bindings for this program. A declaration does not make its name a directly callable tool; only names supplied as separate tool schemas may be called directly.` diff --git a/packages/core/tools/src/types.ts b/packages/core/tools/src/types.ts index 6de9c10714..dfdf34f37f 100644 --- a/packages/core/tools/src/types.ts +++ b/packages/core/tools/src/types.ts @@ -7,17 +7,12 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' -/** Payload recorded when one nested Code Mode Tool dispatch starts. */ -export interface CodeDispatchStartEventData { - rootCallId: ToolCallId - parentCallId: ToolCallId - subCallId: ToolCallId name: string arguments: unknown } -/** Payload recorded when one nested Code Mode Tool dispatch settles. */ -export interface CodeDispatchEventData extends CodeDispatchStartEventData { +/** Payload recorded when one nested PTC mode Tool dispatch settles. */ +export interface PtcDispatchEventData extends PtcDispatchStartEventData { isError: boolean content: ContentBlock[] } @@ -37,7 +32,7 @@ declare module '@deepseek-ai/dsh-session/types' { * with `tool/code-dispatch` by `subCallId` (timing = the two events' * `time` fields). */ - 'tool/code-dispatch-start': CodeDispatchStartEventData + 'tool/code-dispatch-start': PtcDispatchStartEventData /** * One bridged sub-dispatch SETTLING: the pairing ids (matching the * `tool/code-dispatch-start` with the same `subCallId`), the tool `name` @@ -53,6 +48,6 @@ declare module '@deepseek-ai/dsh-session/types' { * before returning), so its execution-enclosure relation holds by * construction. */ - 'tool/code-dispatch': CodeDispatchEventData + 'tool/code-dispatch': PtcDispatchEventData } } diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 24c3c443f4..db3c0b905c 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -89,7 +89,7 @@ describe('tool-pipeline invariants', () => { expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/) }) - it('requires code-dispatch records to be turn-enclosed', async () => { + it('requires ptc-dispatch records to be turn-enclosed', async () => { const ctx = await setup() const session = ctx.sessions.create() const data = { @@ -204,7 +204,7 @@ describe('tool-pipeline invariants', () => { }).not.toThrow() }) - it('replays enclosed code-dispatch records on late registration', async () => { + it('replays enclosed ptc-dispatch records on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create() @@ -223,7 +223,7 @@ describe('tool-pipeline invariants', () => { await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined() }) - it('rejects an unenclosed code-dispatch record on late registration', async () => { + it('rejects an unenclosed ptc-dispatch record on late registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) ctx.sessions.create().append('tool/code-dispatch-start', { diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/ptc.spec.ts similarity index 92% rename from packages/core/tools/tests/code-mode.spec.ts rename to packages/core/tools/tests/ptc.spec.ts index 6bad231737..4031d22cdc 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -15,7 +15,7 @@ import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session' const testToolSignal = new AbortController().signal /** - * Code Mode unit tier (per the Agent Note's plan): provider contribution per mode, + * PTC mode unit tier (per the Agent Note's plan): provider contribution per mode, * misconfiguration rejections, the run_code dispatch bridge (serialization, * abort, JSON normalization, error mapping, events, quiescence), and HMR * safety — all against an in-repo fake runtime, exactly the @@ -50,7 +50,7 @@ interface SetupOptions { async function setup(options: SetupOptions = {}) { const ctx = new Context() await ctx.plugin(SystemPrompt, { ...options.toolOrder ? { toolOrder: options.toolOrder } : {} }) - await ctx.plugin(ToolRuntime, { mode: options.mode ?? 'code', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} }) + await ctx.plugin(ToolRuntime, { mode: options.mode ?? 'ptc', ...options.maxParallelSubCalls !== undefined ? { maxParallelSubCalls: options.maxParallelSubCalls } : {} }) let runtime: FakeRuntime | undefined if (options.runtime !== false) { await ctx.plugin(FakeRuntime, options.runtime ?? {}) @@ -124,8 +124,8 @@ describe('mode-aware wire contribution', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(false) }) - it("mode 'code' contributes exactly [run_code] plus the SDK section declaring the other tools", async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code' }) + it("mode 'ptc' contributes exactly [run_code] plus the SDK section declaring the other tools", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'ptc' }) registerEcho(ctx) const assembly = await systemPrompt.assemble() expect(assembly.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) @@ -136,8 +136,8 @@ describe('mode-aware wire contribution', () => { expect(sdk?.text).not.toContain('tools.bash(') }) - 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' }) + it("mode 'ptc' states the run_code-only rule BEFORE the per-tool guidance that names each tool", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'ptc' }) registerEcho(ctx) // Stand in for a real tool's guidance section, which names its tool without // saying how it is reached. @@ -149,11 +149,11 @@ describe('mode-aware wire contribution', () => { const assembly = await systemPrompt.assemble() const names = assembly.sections.map(section => section.name) - const rule = assembly.sections.find(section => section.name === 'tools:code-only') + const rule = assembly.sections.find(section => section.name === 'tools:ptc-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')) + expect(names.indexOf('tools:ptc-only')).toBeLessThan(names.indexOf('tool:echo')) + expect(names.indexOf('tools:ptc-only')).toBeLessThan(names.indexOf('tools:sdk')) }) it("mode 'both' omits the run_code-only rule, because native calls do execute there", async () => { @@ -162,12 +162,12 @@ describe('mode-aware wire contribution', () => { 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.sections.find(section => section.name === 'tools:ptc-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' }) + it('projects deeply nested output schemas into the PTC mode SDK without structured-clone recursion', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'ptc' }) let output: JsonSchemaNode = { type: 'string' } for (let depth = 0; depth < 5_000; depth++) { output = { oneOf: [output, { type: 'null' }] } @@ -190,7 +190,7 @@ describe('mode-aware wire contribution', () => { expect(sdk).toContain('deep_output: string | null') }) - it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => { + it.each(['ptc', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) registerEcho(ctx) ctx.on('system-prompt/assemble', async (_assembly, _context, next) => { @@ -207,7 +207,7 @@ describe('mode-aware wire contribution', () => { expect(assembly.tools.some(tool => tool.name === RUN_CODE_NAME)).toBe(false) }) - it.each(['code', 'both'] as const)('lets one scope shadow the default SDK section in mode %s', async (mode) => { + it.each(['ptc', 'both'] as const)('lets one scope shadow the default SDK section in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) @@ -231,7 +231,7 @@ describe('mode-aware wire contribution', () => { expect(assembly.sections.some(section => section.name === 'tools:sdk')).toBe(true) }) - it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped allow-list filtering in mode %s', async (mode) => { + it.each(['ptc', 'both'] as const)('keeps the run_code transport outside scoped allow-list filtering in mode %s', async (mode) => { const { ctx, systemPrompt, runtime } = await setup({ mode }) registerEcho(ctx, 'echo') registerEcho(ctx, 'hidden') @@ -239,7 +239,7 @@ describe('mode-aware wire contribution', () => { const lift = scope.ctx.tools.restrict({ allow: ['echo'] }) const assembly = await systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'ptc' ? [RUN_CODE_NAME] : ['echo', RUN_CODE_NAME]) const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text @@ -256,12 +256,12 @@ describe('mode-aware wire contribution', () => { lift() const unrestricted = await systemPrompt.assemble({ scope: agent }) - expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'code' + expect(unrestricted.tools.map(tool => tool.name)).toEqual(mode === 'ptc' ? [RUN_CODE_NAME] : ['echo', 'hidden', RUN_CODE_NAME]) }) - it.each(['code', 'both'] as const)('keeps the run_code transport outside scoped deny-list filtering in mode %s', async (mode) => { + it.each(['ptc', 'both'] as const)('keeps the run_code transport outside scoped deny-list filtering in mode %s', async (mode) => { const { ctx, systemPrompt, runtime } = await setup({ mode }) registerEcho(ctx, 'denied') registerEcho(ctx, 'kept') @@ -269,7 +269,7 @@ describe('mode-aware wire contribution', () => { scope.ctx.tools.restrict({ deny: ['denied'] }) const assembly = await systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'ptc' ? [RUN_CODE_NAME] : ['kept', RUN_CODE_NAME]) const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text @@ -285,7 +285,7 @@ describe('mode-aware wire contribution', () => { expect(result.content).toEqual([{ type: 'text', text: 'kept' }]) }) - it.each(['code', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { + it.each(['ptc', 'both'] as const)('reserves run_code against scoped shadows and explicit restrictions in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode }) const { scope, agent } = await mintAgentScope(ctx) const impostor = defineContentToolFixture({ @@ -295,10 +295,10 @@ describe('mode-aware wire contribution', () => { execute: () => Promise.resolve([{ type: 'text' as const, text: 'impostor' }]), }) - expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) - expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the Code Mode presentation transport/) - expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) - expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved Code Mode presentation transport/) + expect(() => scope.ctx.tools.register(impostor)).toThrow(/reserved for the PTC mode presentation transport/) + expect(() => ctx.tools.register(impostor)).toThrow(/reserved for the PTC mode presentation transport/) + expect(() => scope.ctx.tools.restrict({ allow: [RUN_CODE_NAME] })).toThrow(/cannot name reserved PTC mode presentation transport/) + expect(() => scope.ctx.tools.restrict({ deny: [RUN_CODE_NAME] })).toThrow(/cannot name reserved PTC mode presentation transport/) scope.ctx.systemPrompt.section({ name: 'scoped-note', order: FIRST_PARTY_SECTION_ORDER.TOOLS_SDK - 10, @@ -322,7 +322,7 @@ describe('mode-aware wire contribution', () => { expect(result.content).toEqual([{ type: 'text', text: '(run_code completed with no output)' }]) }) - it.each(['code', 'both'] as const)('keeps run_code in the toolOrder universe without exposing it as a restriction target in mode %s', async (mode) => { + it.each(['ptc', 'both'] as const)('keeps run_code in the toolOrder universe without exposing it as a restriction target in mode %s', async (mode) => { const { ctx, systemPrompt } = await setup({ mode, toolOrder: [RUN_CODE_NAME, ''], @@ -331,7 +331,7 @@ describe('mode-aware wire contribution', () => { const { agent } = await mintAgentScope(ctx) const assembly = await systemPrompt.assemble({ scope: agent }) - expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'code' + expect(assembly.tools.map(tool => tool.name)).toEqual(mode === 'ptc' ? [RUN_CODE_NAME] : [RUN_CODE_NAME, 'echo']) }) @@ -361,7 +361,7 @@ describe('mode-aware wire contribution', () => { }) it('renders byte-identical SDK text across consecutive assemblies of an unchanged tool set', async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code' }) + const { ctx, systemPrompt } = await setup({ mode: 'ptc' }) registerEcho(ctx) const first = await systemPrompt.assemble() const second = await systemPrompt.assemble() @@ -370,17 +370,17 @@ describe('mode-aware wire contribution', () => { }) it('rejects every assembly when a non-native mode has no code runtime', async () => { - const { systemPrompt } = await setup({ mode: 'code', runtime: false }) + const { systemPrompt } = await setup({ mode: 'ptc', runtime: false }) await expect(systemPrompt.assemble()).rejects.toThrow(/requires a code runtime/) }) it('rejects every assembly when the runtime language has no registered SDK renderer', async () => { - const { systemPrompt } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) + const { systemPrompt } = await setup({ mode: 'ptc', runtime: { language: 'ruby' } }) await expect(systemPrompt.assemble()).rejects.toThrow(/no SDK renderer registered for runtime language "ruby"/) }) it('assembles under a python runtime by picking the Python SDK renderer', async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } }) + const { ctx, systemPrompt } = await setup({ mode: 'ptc', runtime: { language: 'python' } }) registerEcho(ctx) const assembly = await systemPrompt.assemble() const sdk = assembly.sections.find(section => section.name === 'tools:sdk') @@ -406,7 +406,7 @@ describe('mode-aware wire contribution', () => { }) it('emits a TypeScript-flavored run_code schema under a typescript runtime', async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'typescript' } }) + const { ctx, systemPrompt } = await setup({ mode: 'ptc', runtime: { language: 'typescript' } }) registerEcho(ctx) const assembly = await systemPrompt.assemble() const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) @@ -421,7 +421,7 @@ describe('mode-aware wire contribution', () => { }) it('emits a Python-flavored run_code schema under a python runtime (matches the SDK language)', async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code', runtime: { language: 'python' } }) + const { ctx, systemPrompt } = await setup({ mode: 'ptc', runtime: { language: 'python' } }) registerEcho(ctx) const assembly = await systemPrompt.assemble() const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) @@ -442,7 +442,7 @@ describe('mode-aware wire contribution', () => { // which throws when the schema is projected. Assembly's // requireCodeRuntime rejects such a language earlier; this reaches the // guard on its own. - const { ctx } = await setup({ mode: 'code', runtime: { language: 'ruby' } }) + const { ctx } = await setup({ mode: 'ptc', runtime: { language: 'ruby' } }) const definition = ctx.tools.get(RUN_CODE_NAME) // Names the known languages, symmetric with the SDK_RENDERERS guard: this // is the reachable rejection, so it must be at least as diagnosable. @@ -457,15 +457,15 @@ describe('mode-aware wire contribution', () => { // returns undefined there, so the flavor getter degrades to the TS default // rather than throwing. None of those readers feeds a model: assembly goes // through wireSchemas, which requires a runtime first. - const { ctx } = await setup({ mode: 'code', runtime: false }) + const { ctx } = await setup({ mode: 'ptc', runtime: false }) const definition = ctx.tools.get(RUN_CODE_NAME) expect(definition?.description).toContain('Execute a TypeScript program') const params = definition?.parameters as { properties: { code: { description: string } } } expect(params.properties.code.description).toBe('The program: the body of an async TypeScript function.') }) - it("rejects the assembly when toolOrder names a native tool that mode 'code' no longer contributes", async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code', toolOrder: ['echo', ''] }) + it("rejects the assembly when toolOrder names a native tool that mode 'ptc' no longer contributes", async () => { + const { ctx, systemPrompt } = await setup({ mode: 'ptc', toolOrder: ['echo', ''] }) registerEcho(ctx) await expect(systemPrompt.assemble()).rejects.toThrow(/toolOrder lists unregistered tool "echo"/) }) @@ -474,7 +474,7 @@ describe('mode-aware wire contribution', () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) await ctx.plugin(FakeRuntime, {}) - const fiber = await ctx.plugin(ToolRuntime, { mode: 'code' }) + const fiber = await ctx.plugin(ToolRuntime, { mode: 'ptc' }) expect(ctx.tools.get(RUN_CODE_NAME)).toBeDefined() await fiber.dispose() const assembly = await ctx.systemPrompt.assemble() @@ -520,7 +520,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { } it('overlaps concurrency-safe calls under Promise.all and logs a start event per dispatch', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const gated = registerGated(ctx, 'safe_read', true) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { @@ -548,7 +548,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('an exclusive call bars overlap: safe calls drain first, it runs alone, later calls wait', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const safe = registerGated(ctx, 'safe_read', true) const unsafe = registerGated(ctx, 'writer', false) runtime.behavior = async (request) => { @@ -578,7 +578,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('maxParallelSubCalls caps the overlap window', async () => { - const { ctx, runtime } = await setup({ mode: 'code', maxParallelSubCalls: 2 }) + const { ctx, runtime } = await setup({ mode: 'ptc', maxParallelSubCalls: 2 }) const gated = registerGated(ctx, 'safe_read', true) runtime.behavior = async (request) => { const tools = request.bindings[0]!.functions @@ -603,7 +603,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('a tool unregistered between binding enumeration and dispatch fails as unknown tool', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls: unknown[] = [] const dispose = ctx.tools.register(defineTool({ name: 'ephemeral', @@ -635,7 +635,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('ordered pre-execute never overlaps: a slow policy on one call delays the next start', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const gated = registerGated(ctx, 'safe_read', true) const stages: string[] = [] let releaseGate: (() => void) | undefined @@ -671,7 +671,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('an exclusive call holds its barrier through post-execute: the next start waits for the commit', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const writer = registerGated(ctx, 'writer', false) const reader = registerGated(ctx, 'safe_read', true) const stages: string[] = [] @@ -708,7 +708,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('run settlement drains a commit already in progress: the settle event is appended inside the turn', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const gated = registerGated(ctx, 'safe_read', true) const { agent, events } = fakeAgent() let releasePost: (() => void) | undefined @@ -742,7 +742,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('post-execute and context commitment stay in submission order under out-of-order completion', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const gated = registerGated(ctx, 'safe_read', true) const postOrder: string[] = [] ctx.on('tools/post-execute', async (postExec, _result, next): Promise => { @@ -778,7 +778,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { }) it('a queued-unstarted call abandoned by run settlement logs no start event', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const gated = registerGated(ctx, 'writer', false) const { agent, events } = fakeAgent() const abandoned: string[] = [] @@ -810,7 +810,7 @@ describe('the sub-dispatch scheduler (native concurrency contract)', () => { describe('the run_code dispatch bridge', () => { it('bridges tool calls, returns only the curated output, and logs one event per dispatch', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { @@ -841,7 +841,7 @@ describe('the run_code dispatch bridge', () => { }) it('exposes only an opaque parent token to nested result observers', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) registerEcho(ctx) runtime.behavior = async (request) => { await request.bindings[0]!.functions.echo!({ value: 'nested' }) @@ -868,7 +868,7 @@ describe('the run_code dispatch bridge', () => { }) it('forwards a nested terminal conclusion onto the successful run_code result', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) ctx.tools.register(defineTool({ name: 'finalize', description: 'Terminal tool.', @@ -908,7 +908,7 @@ describe('the run_code dispatch bridge', () => { }) it('serializes Promise.all dispatches: tool executions never overlap, in submission order', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const intervals: [string, string][] = [] let active = 0 ctx.tools.register(defineTool({ @@ -946,7 +946,7 @@ describe('the run_code dispatch bridge', () => { }) it('rejects the program-side call when the tool errors, with the tool error text', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) ctx.tools.register(defineContentToolFixture({ name: 'fail', description: 'Always fails.', @@ -965,10 +965,10 @@ describe('the run_code dispatch bridge', () => { expect(result.content[0]).toEqual({ type: 'text', text: 'caught: deliberate failure' }) }) - it('a throwing tools/code-dispatch-log listener is contained: the original settled content is logged', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + it('a throwing tools/ptc-dispatch-log listener is contained: the original settled content is logged', async () => { + const { ctx, runtime } = await setup({ mode: 'ptc' }) registerEcho(ctx) - ctx.on('tools/code-dispatch-log', () => { throw new Error('log-content listener failed') }) + ctx.on('tools/ptc-dispatch-log', () => { throw new Error('log-content listener failed') }) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { const value = await request.bindings[0]!.functions.echo!({ value: 'x' }) @@ -981,7 +981,7 @@ describe('the run_code dispatch bridge', () => { }) it('a throwing tools/pre-execute listener settles the sub-call without post-execute', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) const postExecuted: string[] = [] ctx.on('tools/pre-execute', (exec, next) => { @@ -1012,7 +1012,7 @@ describe('the run_code dispatch bridge', () => { }) it('a tools/pre-execute deny reaches the program as a binding rejection', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) registerEcho(ctx) ctx.on('tools/pre-execute', (exec, next) => { if (exec.name === 'echo') return Promise.resolve({ kind: 'deny' as const, reason: 'not on my watch' }) @@ -1032,7 +1032,7 @@ describe('the run_code dispatch bridge', () => { }) it('rejects a binding argument that is not lossless JSON, dispatching nothing', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { @@ -1050,7 +1050,7 @@ describe('the run_code dispatch bridge', () => { }) it('dispatches and logs independent snapshots of the same lossless JSON value', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { @@ -1065,7 +1065,7 @@ describe('the run_code dispatch bridge', () => { }) it('defers sub-call additionalContexts onto the outer run_code result', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) registerEcho(ctx) ctx.on('tools/post-execute', (exec, _result, next): Promise => { if (exec.name === 'echo') { @@ -1101,7 +1101,7 @@ describe('the run_code dispatch bridge', () => { }) it('defers image-bearing final sub-call content onto the outer run_code result', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) ctx.tools.register(defineContentToolFixture({ name: 'image_result', description: 'Return one durable image.', @@ -1136,7 +1136,7 @@ describe('the run_code dispatch bridge', () => { it('does not defer images removed by a nested post-execute decision', async () => { for (const decision of ['block', 'replace'] as const) { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) ctx.tools.register(defineContentToolFixture({ name: 'image_result', description: 'Return one durable image.', @@ -1197,7 +1197,7 @@ describe('the run_code dispatch bridge', () => { }) it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) runtime.behavior = () => Promise.resolve({ logs: ['got this far'], error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' }, @@ -1218,7 +1218,7 @@ describe('the run_code dispatch bridge', () => { }) it('aborting the outer signal aborts the in-flight sub-dispatch and abandons queued ones', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const seen: string[] = [] let sawAbort = false ctx.tools.register(defineContentToolFixture({ @@ -1252,7 +1252,7 @@ describe('the run_code dispatch bridge', () => { }) it('a runtime that starts a binding call and then REJECTS still reaches quiescence before returning', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const { agent, events } = fakeAgent() let sawAbort = false let started!: () => void @@ -1287,7 +1287,7 @@ describe('the run_code dispatch bridge', () => { }) it('runs without an owning agent: dispatches work, event logging is skipped', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) runtime.behavior = async (request) => { await request.bindings[0]!.functions.echo!({ value: 'x' }) @@ -1301,14 +1301,14 @@ describe('the run_code dispatch bridge', () => { it('executing run_code under a missing runtime is a structured isError, not a crash', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) const result = await runCode(ctx, 'program') expect(result.isError).toBe(true) expect((result.content[0] as { text: string }).text).toContain('requires a code runtime') }) it('presents the model-authored description as the execute-card title over the program input', async () => { - const { ctx } = await setup({ mode: 'code' }) + const { ctx } = await setup({ mode: 'ptc' }) const tool = ctx.tools.get(RUN_CODE_NAME)! // The description labels the card (the bash description precedent); the // program itself remains the expanded raw input. @@ -1321,7 +1321,7 @@ describe('the run_code dispatch bridge', () => { }) it('rejects a whitespace-only description with a structured isError', async () => { - const { ctx } = await setup({ mode: 'code' }) + const { ctx } = await setup({ mode: 'ptc' }) const result = await runCode(ctx, 'return 1', { description: ' ' }) expect(result.isError).toBe(true) expect((result.content[0] as { text: string }).text).toContain('invalid description') @@ -1333,7 +1333,7 @@ describe('the run_code dispatch bridge', () => { ['logs plus result', { logs: ['printed'], value: 'returned' }, 'printed\nreturned'], ['no output', { logs: [] }, '(run_code completed with no output)'], ] as [string, CodeRunResult, string][])('keeps %s in durable content without a result presenter', async (_name, output, text) => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) runtime.behavior = () => Promise.resolve(output) const result = await runCode(ctx, 'return 1') @@ -1347,7 +1347,7 @@ describe('the run_code dispatch bridge', () => { }) it('keeps a post-policy spill preview in durable content without a result presenter', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const preview = 'HEAD\n\n(Omitted 100 bytes. Full formatted result stored at: /tmp/run-code.txt.)\n\nTAIL' runtime.behavior = () => Promise.resolve({ logs: ['printed'], value: 'returned' }) ctx.on('tools/post-execute', (exec, _result, next): Promise => { @@ -1363,7 +1363,7 @@ describe('the run_code dispatch bridge', () => { }) it('keeps canonical failure content durable without a result presenter', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) runtime.behavior = () => Promise.resolve({ logs: ['captured before failure'], error: { kind: 'output-limit', message: 'outer output exceeded 8 bytes' }, @@ -1381,7 +1381,7 @@ describe('the run_code dispatch bridge', () => { }) it('logs the complete sub-result content verbatim, non-text blocks and long text included', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const { agent, events } = fakeAgent() const long = 'x'.repeat(300) ctx.tools.register(defineTool({ @@ -1414,7 +1414,7 @@ describe('the run_code dispatch bridge', () => { }) it('rejects undefined, getter-throwing, exotic, and unrepresentable binding arguments before dispatch', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) const { agent, events } = fakeAgent() runtime.behavior = async (request) => { @@ -1446,7 +1446,7 @@ describe('the run_code dispatch bridge', () => { }) it('dispatches and durably logs binding arguments deeper than the structured-clone call stack', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const depth = 5_000 let observedDepth = 0 let observedLeaf: JsonValue | undefined @@ -1497,7 +1497,7 @@ describe('the run_code dispatch bridge', () => { }) it('gives the tool and durable log the same immutable argument value', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const { agent, events } = fakeAgent() let mutationSucceeded: boolean | undefined ctx.tools.register(defineContentToolFixture({ @@ -1521,7 +1521,7 @@ describe('the run_code dispatch bridge', () => { }) it('exposes a tool named __proto__ as an ordinary own binding', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) ctx.tools.register(defineTool({ name: '__proto__', description: 'A prototype-colliding tool name.', @@ -1544,7 +1544,7 @@ describe('the run_code dispatch bridge', () => { }) it('renders every non-string JSON root as pretty JSON while preserving strings raw', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) runtime.behavior = () => Promise.resolve({ logs: [], value: { n: 42, ok: true } }) expect((await runCode(ctx, 'object')).content[0]).toEqual({ type: 'text', text: '{\n "n": 42,\n "ok": true\n}' }) runtime.behavior = () => Promise.resolve({ logs: [], value: {} }) @@ -1567,7 +1567,7 @@ describe('the run_code dispatch bridge', () => { }) it('renders deeply nested JSON without recursive traversal or quadratic indentation', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) let value: JsonValue = { emptyArray: [], emptyObject: {}, @@ -1588,7 +1588,7 @@ describe('the run_code dispatch bridge', () => { }) it('short-circuits a pre-aborted outer signal before the code runtime', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) runtime.behavior = (request) => { // The fake honors the seam contract for an already-aborted signal. @@ -1612,7 +1612,7 @@ describe('the run_code dispatch bridge', () => { }) it('reports cancellation after rejecting a late binding without dispatching it', async () => { - const { ctx, runtime } = await setup({ mode: 'code' }) + const { ctx, runtime } = await setup({ mode: 'ptc' }) const calls = registerEcho(ctx) const controller = new AbortController() runtime.behavior = async (request) => { @@ -1632,7 +1632,7 @@ describe('the run_code dispatch bridge', () => { }) it('a tool/code-dispatch event never derives a model message', () => { - const session = Session.create(SessionId('code-mode-derive')) + const session = Session.create(SessionId('ptc-derive')) session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' }, }), { surfaceOp: 'append' }) @@ -1653,14 +1653,14 @@ describe('the run_code dispatch bridge', () => { it('direct construction rejects a non-positive parallel sub-call cap at load', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) - expect(() => new ToolRuntime(ctx, { mode: 'code', maxParallelSubCalls: 0 })) + expect(() => new ToolRuntime(ctx, { mode: 'ptc', maxParallelSubCalls: 0 })) .toThrow('maxParallelSubCalls must be a positive integer') }) it('direct construction in code mode defaults the parallel sub-call cap', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'code' }) + const registry = new ToolRuntime(ctx, { mode: 'ptc' }) expect(registry.get(RUN_CODE_NAME)).toBeDefined() }) @@ -1675,7 +1675,7 @@ describe('the run_code dispatch bridge', () => { 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 ToolRuntime(ctx, { mode: 'code' }) + const registry = new ToolRuntime(ctx, { mode: 'ptc' }) registerEcho(ctx, 'write') const result = await registry.execute({ signal: testToolSignal, @@ -1695,7 +1695,7 @@ describe('the run_code dispatch bridge', () => { it('routes a pre-aborted collapsed call through ABORTED_BEFORE_DISPATCH', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) - const registry = new ToolRuntime(ctx, { mode: 'code' }) + const registry = new ToolRuntime(ctx, { mode: 'ptc' }) registerEcho(ctx, 'write') const aborted = new AbortController() aborted.abort() @@ -1713,17 +1713,17 @@ describe('the run_code dispatch bridge', () => { /** * Presentation is per agent, because an agent preset composes it: one - * deployment runs a Code Mode agent beside native ones, and neither may see + * deployment runs a PTC mode agent beside native ones, and neither may see * the other's catalog. The deployment `mode` is the default those agents * shadow, not a process-wide fact. */ describe('per-agent presentation', () => { - it('gives one agent Code Mode while the deployment stays native', async () => { + it('gives one agent PTC mode while the deployment stays native', async () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) const calls = registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('code') + scope.ctx.tools.presentAs('ptc') const coded = await systemPrompt.assemble({ scope: agent }) expect(coded.tools.map(tool => tool.name)).toEqual([RUN_CODE_NAME]) @@ -1753,8 +1753,8 @@ describe('per-agent presentation', () => { 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') - standing.scope.ctx.tools.presentAs('code') + const standing = await mintAgentScope(ctx, 'preset:ptc-like') + standing.scope.ctx.tools.presentAs('ptc') const joined = await mintAgentScope(ctx, 'joined-agent') bindScopeParent(joined.agent, standing.agent) const loner = await mintAgentScope(ctx, 'loner-agent') @@ -1803,7 +1803,7 @@ describe('per-agent presentation', () => { registerEcho(ctx) const coded = await mintAgentScope(ctx, 'coded') const plain = await mintAgentScope(ctx, 'plain') - coded.scope.ctx.tools.presentAs('code') + coded.scope.ctx.tools.presentAs('ptc') // Not merely hidden from the prompt: the transport one agent presents must // not be dispatchable by another that never presented it. @@ -1812,8 +1812,8 @@ describe('per-agent presentation', () => { expect(ctx.tools.get(RUN_CODE_NAME)).toBeUndefined() }) - it('lets an agent opt out of a code-mode deployment', async () => { - const { ctx, systemPrompt } = await setup({ mode: 'code' }) + it('lets an agent opt out of a PTC mode deployment', async () => { + const { ctx, systemPrompt } = await setup({ mode: 'ptc' }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) @@ -1830,7 +1830,7 @@ describe('per-agent presentation', () => { const { ctx, systemPrompt } = await setup({ mode: 'native' }) registerEcho(ctx) const { scope, agent } = await mintAgentScope(ctx) - const dispose = scope.ctx.tools.presentAs('code') + const dispose = scope.ctx.tools.presentAs('ptc') dispose() @@ -1842,18 +1842,18 @@ describe('per-agent presentation', () => { it('refuses a second declaration for the same agent', async () => { const { ctx } = await setup({ mode: 'native' }) const { scope } = await mintAgentScope(ctx) - scope.ctx.tools.presentAs('code') + scope.ctx.tools.presentAs('ptc') // Two answers to "which form does the model see" is a contradiction, and // silently keeping either one would make the composition unreadable. expect(() => scope.ctx.tools.presentAs('both')) - .toThrow('conflicts with "code" already declared') + .toThrow('conflicts with "ptc" already declared') }) it('refuses an unscoped declaration', async () => { const { ctx } = await setup({ mode: 'native' }) - expect(() => ctx.tools.presentAs('code')) + expect(() => ctx.tools.presentAs('ptc')) .toThrow('requires a scoped context') }) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 0caf9daab6..92fc269332 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -428,7 +428,7 @@ describe('renderToolsSdkPy', () => { // `路径` satisfies `xid_start xid_continue*`, so CPython accepts it as an // attribute and as the `TypedDict` key. Rejecting it would degrade the // whole object, dropping every SIBLING field's name, requiredness and type - // too — and under `mode: 'code'` the native schemas are omitted, so + // too — and under `mode: 'ptc'` the native schemas are omitted, so // nothing else carries them. The nested class name is from the field, so // `camelCase` has to pass the same characters through instead of splitting // on them. @@ -1037,7 +1037,7 @@ describe('renderToolsSdkPy', () => { // `__debug__` is a legal identifier and dunder-form, so it clears both the // identifier rule and the name-mangling rule, but CPython rejects the // annotation at COMPILE time (`SyntaxError: cannot assign to __debug__`) — - // and this block is Code Mode's only SDK, so it must always parse. + // and this block is PTC mode's only SDK, so it must always parse. const t: ToolSdkSchema = { name: 'debugger', description: '', @@ -1111,7 +1111,7 @@ describe('renderToolsSdkPy', () => { // `compile()` raises `SyntaxError: source code string cannot contain null // bytes` for a NUL ANYWHERE in the source text, including inside a string // literal or a comment, so a NUL that survives normalization into a - // docstring or a `#` field comment stops this block — Code Mode's only SDK — + // docstring or a `#` field comment stops this block — PTC mode's only SDK — // from parsing at all. The whitespace collapse does not remove it (a NUL is // not whitespace). Rendering it as a visible escape keeps the source // parseable and still shows the model what the schema said. @@ -1156,7 +1156,7 @@ describe('renderToolsSdkPy', () => { // This is the NUL case, not the invisible-character case: Python source // must be UTF-8-encodable, and `compile()` raises `UnicodeEncodeError: // surrogates not allowed` for a lone surrogate in a string literal and in a - // `#` comment alike, so one would stop this block — Code Mode's only SDK — + // `#` comment alike, so one would stop this block — PTC mode's only SDK — // from parsing. A wire description reaches it: `JSON.parse` on a `"\ud800"` // escape yields exactly this code point. const high = renderToolsSdkPy([described('a\ud800b')]) diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 583685a699..7244444245 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -65,7 +65,7 @@ class CatalogAdapter extends LlmAdapter { } } -/** In-process Code Mode seam fake that invokes the real registry bindings. */ +/** In-process PTC mode seam fake that invokes the real registry bindings. */ class FakeRuntime extends CodeRuntime { readonly language = 'typescript' readonly isolation = 'fake' @@ -101,7 +101,7 @@ async function setup(options: SetupOptions = {}) { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime, { mode: options.toolMode ?? 'native' }) - if (options.toolMode === 'code' || options.toolMode === 'both') { + if (options.toolMode === 'ptc' || options.toolMode === 'both') { await ctx.plugin(FakeRuntime) } await ctx.plugin(LocalFileSystem, { cwd: dir }) @@ -226,9 +226,9 @@ describe('read_image happy path', () => { expect(result.isError).toBe(false) }) - it('forwards a nested Code Mode image through the outer run_code context', async () => { + it('forwards a nested PTC mode image through the outer run_code context', async () => { await writeFile(join(dir, 'red.png'), PNG_1X1) - const ctx = await setup({ toolMode: 'code' }) + const ctx = await setup({ toolMode: 'ptc' }) const runtime = ctx.codeRuntime as FakeRuntime runtime.behavior = async (request) => { const value = await request.bindings[0]!.functions.read_image!({ file_path: 'red.png' }) @@ -237,7 +237,7 @@ describe('read_image happy path', () => { const result = await call(ctx, RUN_CODE_NAME, { code: 'return await tools.read_image({ file_path: "red.png" })', - description: 'Read the image through Code Mode', + description: 'Read the image through PTC mode', }, agentOn('vision-model')) expect(result.isError).toBe(false) diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 8c4780f71d..4c6ab40fbd 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -36,7 +36,7 @@ export interface ToolBridgeOptions { /** State for one sync generation: the current set of disposers keyed by public name. */ export type ToolDisposers = Map void> -/** Canonical MCP result exposed to Code Mode without discarding protocol blocks. */ +/** Canonical MCP result exposed to PTC mode without discarding protocol blocks. */ export type McpResult = { content: JsonValue[] structuredContent?: Structured diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index f08769c155..f9717e0f39 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -138,7 +138,7 @@ function registerNamedTools(ctx: Context, names: string[]): void { } } -/** Assert the mapped Code Mode SDK includes the stable plan exit binding and test tools. */ +/** Assert the mapped PTC mode SDK includes the stable plan exit binding and test tools. */ function expectPlanCodeSdkBindings(sdk: string): void { expect(sdk).toContain('interface ToolArgsMap {') expect(sdk).toContain('read: Record;') @@ -456,9 +456,9 @@ describe('the soft layer', () => { .toEqual(['exit_plan_mode', 'read', 'added-later']) }) - it('keeps run_code the only wire tool in plan mode under the registry Code Mode; the SDK gains the exit binding', async () => { + it('keeps run_code the only wire tool in plan mode under the registry PTC mode; the SDK gains the exit binding', async () => { // Minimal scriptable runtime: the SDK section resolves ctx.codeRuntime at - // assembly time (the code-mode.spec fake's shape). + // assembly time (the ptc.spec fake's shape). class FakeRuntime extends CodeRuntime { readonly language = 'typescript' readonly isolation = 'fake' @@ -466,7 +466,7 @@ describe('the soft layer', () => { } const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) await ctx.plugin(FakeRuntime) await ctx.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(ctx, ['read', 'write']) @@ -500,7 +500,7 @@ describe('the soft layer', () => { expectPlanCodeSdkBindings(sdk) }) - it('keeps the Code Mode SDK byte-identical across mode switches', async () => { + it('keeps the PTC mode SDK byte-identical across mode switches', async () => { class FakeRuntime extends CodeRuntime { readonly language = 'typescript' readonly isolation = 'fake' @@ -508,7 +508,7 @@ describe('the soft layer', () => { } const withPlanMode = new Context() await withPlanMode.plugin(SystemPrompt) - await withPlanMode.plugin(ToolRuntime, { mode: 'code' }) + await withPlanMode.plugin(ToolRuntime, { mode: 'ptc' }) await withPlanMode.plugin(FakeRuntime) await withPlanMode.plugin(PlanModeController, PLAN_CONFIG) registerNamedTools(withPlanMode, ['read', 'write']) @@ -523,7 +523,7 @@ describe('the soft layer', () => { // with a deployment that does not compose plan mode at all. const bare = new Context() await bare.plugin(SystemPrompt) - await bare.plugin(ToolRuntime, { mode: 'code' }) + await bare.plugin(ToolRuntime, { mode: 'ptc' }) await bare.plugin(FakeRuntime) registerNamedTools(bare, ['read', 'write']) const bareSdk = (await bare.systemPrompt.assemble({ agent })).sections.find(section => section.name === 'tools:sdk')?.text ?? '' @@ -854,8 +854,8 @@ describe('exit_plan_mode', () => { expect(asked[0]?.questions[0]?.options?.map(option => option.label)).toEqual(['Approve', 'Keep planning']) }) - it('carries the exact plan through a Code Mode review and logs the nested dispatch', async () => { - const plan = '# Code Mode plan\n\nUse the existing seam.' + it('carries the exact plan through a PTC mode review and logs the nested dispatch', async () => { + const plan = '# PTC mode plan\n\nUse the existing seam.' class ExitRuntime extends CodeRuntime { readonly language = 'typescript' readonly isolation = 'fake' @@ -867,7 +867,7 @@ describe('exit_plan_mode', () => { } const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) await ctx.plugin(ExitRuntime) await ctx.plugin(PlanModeController, PLAN_CONFIG) await ctx.plugin(AgentRegistry) @@ -879,7 +879,7 @@ describe('exit_plan_mode', () => { return Promise.resolve({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }) }, }) - const agent = await agentWithSession(ctx, 'code-mode-exit', { active: true }) + const agent = await agentWithSession(ctx, 'ptc-exit', { active: true }) const result = await ctx.tools.execute({ callId: ToolCallId(`call-exit-${++callCounter}`), diff --git a/packages/preset/agent-presets/presets/code/preset.yml b/packages/preset/agent-presets/presets/code/preset.yml deleted file mode 100644 index fc0836f479..0000000000 --- a/packages/preset/agent-presets/presets/code/preset.yml +++ /dev/null @@ -1,3 +0,0 @@ -name: PTC 模式 -description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 -order: 2 diff --git a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md index c9adee9961..14ddd3b1d7 100644 --- a/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/packages/preset/agent-presets/presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -9,7 +9,7 @@ Every capability in this harness is a plugin row in a `cordis.yml`. There is no ## Off-limits -**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. +**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. diff --git a/packages/preset/agent-presets/presets/code/agent.cordis.yml b/packages/preset/agent-presets/presets/ptc/agent.cordis.yml similarity index 98% rename from packages/preset/agent-presets/presets/code/agent.cordis.yml rename to packages/preset/agent-presets/presets/ptc/agent.cordis.yml index e3bbe8fad2..3c8406f907 100644 --- a/packages/preset/agent-presets/presets/code/agent.cordis.yml +++ b/packages/preset/agent-presets/presets/ptc/agent.cordis.yml @@ -1,4 +1,4 @@ -# The `code` agent preset: the standard coding agent, presented as Code Mode. +# The `ptc` agent preset: the standard coding agent, presented as PTC mode. # # Everything in `standard` is here unchanged. What is added is the `tool-presentation` # row: instead of one tool call per action, the model writes a TypeScript @@ -259,10 +259,10 @@ # ── presentation ──────────────────────────────────────────────────────────── -# Code Mode for this agent alone. The row waits for the host's `codeRuntime` +# PTC mode for this agent alone. The row waits for the host's `codeRuntime` # rather than assuming it: a deployment that composes no TypeScript runtime # fails this preset at mount, naming this id, instead of at the first request. - id: tool-presentation name: '@deepseek-ai/dsh-agent-tool-presentation' config: - mode: code + mode: ptc diff --git a/packages/preset/agent-presets/presets/ptc/preset.yml b/packages/preset/agent-presets/presets/ptc/preset.yml new file mode 100644 index 0000000000..e0351de114 --- /dev/null +++ b/packages/preset/agent-presets/presets/ptc/preset.yml @@ -0,0 +1,3 @@ +name: PTC 模式 +description: 具备标准模式的全部能力,并通过 PTC 模式 SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 +order: 2 diff --git a/packages/preset/agent-presets/tests/shipped-root.spec.ts b/packages/preset/agent-presets/tests/shipped-root.spec.ts index 85ede1a90a..d45f3a1373 100644 --- a/packages/preset/agent-presets/tests/shipped-root.spec.ts +++ b/packages/preset/agent-presets/tests/shipped-root.spec.ts @@ -56,7 +56,7 @@ describe('the shipped preset root', () => { const ctx = await roster({ includeUserRoot: false }) const listed = await ctx.agentPresets.list() - expect(listed.map(preset => preset.id).sort()).toEqual(['code', 'cordis', 'minimal', 'standard']) + expect(listed.map(preset => preset.id).sort()).toEqual(['cordis', 'minimal', 'ptc', 'standard']) expect(listed.every(preset => preset.trust === 'system')).toBe(true) // Not `broken === undefined`: health asks whether each row's package is // installed above the base, and the shipped rows name packages the @@ -96,7 +96,7 @@ describe('the shipped preset root', () => { }) it('enables web_fetch in each tool-bearing Web app preset', async () => { - for (const id of ['cordis', 'code', 'standard']) { + for (const id of ['cordis', 'ptc', 'standard']) { const source = await readFile(join(SHIPPED_PRESET_ROOT, id, 'agent.cordis.yml'), 'utf8') const entries: unknown = yaml.load(source, { schema: entryListSchema }) if (!Array.isArray(entries)) throw new TypeError(`${id} preset must contain a Cordis entry list`) diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index d6ebb1b664..058972c9eb 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -11,7 +11,7 @@ * The policy only decides WHEN to spill and composes the notice. * * A second arm applies the SAME cap to the durable log: the - * `tools/code-dispatch-log` waterfall bounds the `tool/code-dispatch` event's + * `tools/ptc-dispatch-log` waterfall bounds the `tool/ptc-dispatch` event's * copy of an oversized `run_code` sub-call result (the program's value is * untouched; UIs and replay read the full text through the spill artifact). * @@ -208,13 +208,13 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } }, { prepend: true }) - // The durable-log arm: bound the `tool/code-dispatch` event's copy of an + // The durable-log arm: bound the `tool/ptc-dispatch` event's copy of an // oversized sub-call result the same way the model-facing arm bounds an // outer result. The program's returned value is untouched (it already // crossed the worker boundary whole); only the session log's copy shrinks // to preview + locator, so replay and UIs read the full text through the // spill artifact exactly as they do for spilled native results. - ctx.on('tools/code-dispatch-log', async (dispatch, next): Promise => { + ctx.on('tools/ptc-dispatch-log', async (dispatch, next): Promise => { const content = await next() // `read` sub-calls spill too: the log copy is not model context, so the // read → spill → read-again loop the post-execute arm avoids cannot diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 433d2c1797..375a9b3fd7 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -186,11 +186,11 @@ describe('oversized plain-text replacement', () => { }) }) -describe('outer Code Mode failure capture', () => { +describe('outer PTC mode failure capture', () => { it('spills the bounded output-limit diagnostic through the ordinary outer-result policy', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineBytes: 200 }) await ctx.plugin(WorkerThreadCodeRuntime, { maxOutputBytes: 500 }) @@ -239,7 +239,7 @@ describe('the durable dispatch-log arm', () => { async function runCodeWith(program: string, maxInlineBytes: number, extraTools: ToolDefinition[] = []) { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineBytes }) await ctx.plugin(WorkerThreadCodeRuntime, {}) @@ -313,7 +313,7 @@ describe('the durable dispatch-log arm', () => { it('a slow spill backend never delays the program value or a later dispatch slot', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 }) await ctx.plugin(WorkerThreadCodeRuntime, {}) @@ -377,7 +377,7 @@ describe('the durable dispatch-log arm', () => { // lane holds inside the second commit, so the THIRD dispatch cannot start // until a pending save drains — the bound is observable as its missing // start event. - await ctx.plugin(ToolRuntime, { mode: 'code', maxParallelSubCalls: 1 }) + await ctx.plugin(ToolRuntime, { mode: 'ptc', maxParallelSubCalls: 1 }) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 }) await ctx.plugin(WorkerThreadCodeRuntime, {}) @@ -430,7 +430,7 @@ describe('the durable dispatch-log arm', () => { it('a saveText failure keeps the complete content in the durable log (best-effort)', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime, { mode: 'code' }) + await ctx.plugin(ToolRuntime, { mode: 'ptc' }) await ctx.plugin(StubStore) await ctx.plugin(SpillPolicy, { maxInlineBytes: 100 }) await ctx.plugin(WorkerThreadCodeRuntime, {}) diff --git a/packages/subagent/subagent-in-process-driver/src/structured.ts b/packages/subagent/subagent-in-process-driver/src/structured.ts index 1b764d7a4c..170a6f706b 100644 --- a/packages/subagent/subagent-in-process-driver/src/structured.ts +++ b/packages/subagent/subagent-in-process-driver/src/structured.ts @@ -4,7 +4,7 @@ * scope, so concurrent runs do not interact and disposal leaves no global residue. The prompt * contribution is ordinary reconstructed request state. * - * Capture commits only after the authoritative `tools/result` succeeds; Code Mode capture also + * Capture commits only after the authoritative `tools/result` succeeds; PTC mode capture also * waits for the enclosing `run_code` result. The terminal result marker and monotonic tool * guard prevent later calls from reopening a completed structured run. * @module @deepseek-ai/dsh-subagent-in-process-driver/structured @@ -124,7 +124,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch /* v8 ignore else -- sequential agent-loop dispatch lets the guard block every later supported call */ if (captured === undefined) captured = { value: entry.value } } else { - /* v8 ignore else -- Code Mode serializes sub-dispatches, so the guard blocks every later supported call */ + /* v8 ignore else -- PTC mode serializes sub-dispatches, so the guard blocks every later supported call */ if (captured === undefined && pending === undefined) { pending = { parent: exec.parent, value: entry.value } } @@ -135,7 +135,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSch const entry = pending pending = undefined if (result.isError) return - /* v8 ignore else -- Code Mode serializes outer executions, so the guard blocks every later supported call */ + /* v8 ignore else -- PTC mode serializes outer executions, so the guard blocks every later supported call */ if (captured === undefined) captured = { value: entry.value } }) diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts index 8f21ee4483..7cafcd8e08 100644 --- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts +++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts @@ -59,7 +59,7 @@ async function setup(script: Script, options: SetupOptions = {}) { await mountAgentLoopTestDependencies(ctx, { tools: { mode: options.toolMode ?? 'native' }, }) - if (options.toolMode === 'code' || options.toolMode === 'both') { + if (options.toolMode === 'ptc' || options.toolMode === 'both') { ctx.provide('codeRuntime', { language: 'typescript', isolation: 'test', @@ -383,11 +383,11 @@ describe('in-process structured output', () => { await run.dispose() }) - it('keeps pure Code Mode at one wire tool and exposes structured capture through the SDK only', async () => { + it('keeps pure PTC mode at one wire tool and exposes structured capture through the SDK only', async () => { const { ctx, parent, adapter } = await setup([ toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }), ], { - toolMode: 'code', + toolMode: 'ptc', codeRun: async (request) => { const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL] if (!capture) throw new Error('structured_output binding missing') @@ -414,7 +414,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', RUN_CODE_NAME, { code: 'await tools.structured_output({ answer: 12 }); throw new Error("boom")', description: 'Capture then fail the program' }), textResponse('outer code failed'), ], { - toolMode: 'code', + toolMode: 'ptc', codeRun: async (request) => { const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL] if (!capture) throw new Error('structured_output binding missing') @@ -443,7 +443,7 @@ describe('in-process structured output', () => { toolCallResponse('c1', RUN_CODE_NAME, { code: 'return await tools.structured_output({ answer: 12 })', description: 'Capture the structured answer' }), textResponse('outer code was blocked'), ], { - toolMode: 'code', + toolMode: 'ptc', codeRun: async (request) => { const capture = request.bindings.at(0)?.functions[STRUCTURED_OUTPUT_TOOL] if (!capture) throw new Error('structured_output binding missing') diff --git a/packages/terminal/tool-terminal/tests/tools.spec.ts b/packages/terminal/tool-terminal/tests/tools.spec.ts index 406ae59778..15151f7bd2 100644 --- a/packages/terminal/tool-terminal/tests/tools.spec.ts +++ b/packages/terminal/tool-terminal/tests/tools.spec.ts @@ -186,7 +186,7 @@ describe('tool-terminal foreground API', () => { expect(empty).toMatchObject({ isError: false, value: [] }) }) - it('projects every terminal DTO into the generated Code Mode output map', async () => { + it('projects every terminal DTO into the generated PTC mode output map', async () => { const { ctx } = await setup(false) const schemas = TOOL_NAMES.map((toolName): ToolSdkSchema => { const definition = ctx.tools.get(toolName) diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-ptc.mjs similarity index 58% rename from scripts/demo-code-mode.mjs rename to scripts/demo-ptc.mjs index a7de910ca4..d0c5c059bc 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-ptc.mjs @@ -1,8 +1,8 @@ -/** Run one headless task through the shipped Code Mode composition. Requires a model credential. */ +/** Run one headless task through the shipped PTC mode composition. Requires a model credential. */ import { spawn } from 'node:child_process' const task = process.argv.slice(2).join(' ').trim() - || 'Inspect this repository with Code Mode and report its top-level architecture.' + || 'Inspect this repository with PTC mode and report its top-level architecture.' const child = spawn(process.execPath, [ '--import', @@ -13,6 +13,6 @@ const child = spawn(process.execPath, [ task, ], { stdio: 'inherit', - env: { ...process.env, DSH_TOOLS_MODE: 'code' }, + env: { ...process.env, DSH_TOOLS_MODE: 'ptc' }, }) child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index dcca3d0722..1bae81253b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -517,7 +517,7 @@ export const LINK_MAP: Readonly> = { TeamWaitResult: 'agent-team.md', UpdateTeamTaskRequest: 'agent-team.md', TokenMeasurement: 'token-meter.md', - CodeDispatchLog: 'tools.md', + PtcDispatchLog: 'tools.md', PostToolDecision: 'tools.md', PreToolDecision: 'tools.md', ToolDefinition: 'tools.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 65876fd5d0..0ea6043121 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -353,7 +353,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Tool registry and guarded execution pipeline', mode: 'core', consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'], - note: 'Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.', + note: 'Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.', }, { key: 'userQuestions', @@ -525,7 +525,7 @@ const SERVICE_ROLES: ServiceRole[] = [ mode: 'seam', implementations: ['code-runtime-worker-thread'], consumers: ['tools'], - note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode).', + note: 'Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for PTC mode).', }, { key: 'fs', @@ -1432,7 +1432,7 @@ function renderToolPipeline(): string { ' allResults --> context', '```', '', - 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. Code Mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', + 'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/code-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.', '', ...maintenanceFooter(maintenance), ].join('\n') diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 6cfbc4d618..36e0b0833c 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -204,16 +204,16 @@ const TOOL_PACKAGES: ToolPackage[] = [ { pkg: '@deepseek-ai/dsh-tools', dir: 'tools', - source: 'packages/core/tools/src/code-mode.ts', + source: 'packages/core/tools/src/ptc.ts', requires: ['ctx.tools', 'ctx.codeRuntime (execution time)', 'ctx.systemPrompt'], writes: ['tool/call', 'one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call', 'tool/result'], // The registry's OWN tool: run_code exists only under a non-native mode // (the registry registers it in its constructor; the code runtime is read // at assembly/execution time, so the schema harvest needs none mounted). - toolsConfig: { mode: 'code' }, + toolsConfig: { mode: 'ptc' }, async mount() {}, note: - 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', + 'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.', }, { pkg: '@deepseek-ai/dsh-plan-mode', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index b54672eb87..3820d48ea4 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -793,7 +793,7 @@ }, { "doc": "docs/subsystems/tools.md", - "symbol": "CodeDispatchLog", + "symbol": "PtcDispatchLog", "source": "packages/core/tools/src/index.ts" }, { diff --git a/scripts/verify-application-entrypoints.spec.ts b/scripts/verify-application-entrypoints.spec.ts index b58cb7b74b..a03240eb1c 100644 --- a/scripts/verify-application-entrypoints.spec.ts +++ b/scripts/verify-application-entrypoints.spec.ts @@ -86,12 +86,12 @@ describe('application entrypoints', () => { it('rejects a classified demo wrapper that launches a package entry', () => { const root = fixture() - write(root, 'package.json', JSON.stringify({ scripts: { 'demo:code-mode': 'node scripts/demo-code-mode.mjs' } })) - write(root, 'scripts/demo-code-mode.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n") + write(root, 'package.json', JSON.stringify({ scripts: { 'demo:ptc': 'node scripts/demo-ptc.mjs' } })) + write(root, 'scripts/demo-ptc.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n") expect(applicationEntrypointViolations(root)).toEqual([ - 'scripts/demo-code-mode.mjs: application demo wrapper must launch apps/cli/src/bin.ts', - 'scripts/demo-code-mode.mjs: application demo wrapper must not launch a package entry directly', + 'scripts/demo-ptc.mjs: application demo wrapper must launch apps/cli/src/bin.ts', + 'scripts/demo-ptc.mjs: application demo wrapper must not launch a package entry directly', ]) }) diff --git a/scripts/verify-application-entrypoints.ts b/scripts/verify-application-entrypoints.ts index 964383a62f..d80deb1dce 100644 --- a/scripts/verify-application-entrypoints.ts +++ b/scripts/verify-application-entrypoints.ts @@ -48,7 +48,7 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map([ /** Root demos are application wrappers and therefore must visibly select dsh. */ const ROOT_DEMO_POLICIES = new Map([ - ['demo:code-mode', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-code-mode.mjs' }], + ['demo:ptc', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-ptc.mjs' }], ['demo:inspector', { kind: 'dsh-direct' }], ]) diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 46e80a86c1..1caf31ec91 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -49,10 +49,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, 'packages/shell/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' }, - 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to PTC mode in dsh-tools.' }, 'packages/core/agent-tool-presentation': { kind: 'indirect', reason: 'The row only selects between the two projections dsh-tools owns; it registers no prompt, schema, or result of its own.' }, - 'packages/code-runtime/code-runtime-worker-thread': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' }, - 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to Code Mode in dsh-tools.' }, + 'packages/code-runtime/code-runtime-worker-thread': { kind: 'indirect', reason: 'The worker backend delegates model rendering to PTC mode in dsh-tools.' }, + 'packages/code-runtime/code-runtime-python': { kind: 'indirect', reason: 'The CPython subprocess backend delegates model rendering to PTC mode in dsh-tools.' }, 'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' }, 'packages/util/crypto': { kind: 'indirect', reason: 'Pure identifier minting; the ids consumers mint with it never enter prompts as semantic content.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, diff --git a/snapshots/session/code-mode-read-image/snapshot.yml b/snapshots/session/code-mode-read-image/snapshot.yml deleted file mode 100644 index 36c929b8e5..0000000000 --- a/snapshots/session/code-mode-read-image/snapshot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 1 -scenario: code-mode-read-image -profile: headless -composition: code-image -recording: authored -header: - class: code-image - pin: true - toolSchemasSource: code-mode-turn -platform: posix -workspace: - final: true diff --git a/snapshots/session/code-mode-workspace-context/snapshot.yml b/snapshots/session/code-mode-workspace-context/snapshot.yml deleted file mode 100644 index e16b7e5793..0000000000 --- a/snapshots/session/code-mode-workspace-context/snapshot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 1 -scenario: code-mode-workspace-context -profile: headless -composition: code-workspace-context -recording: authored -header: - class: code-workspace-context - pin: true - systemPromptSource: code-mode-turn - toolSchemasSource: code-mode-turn -replay: - override: true diff --git a/snapshots/session/cordis-inspect-jsdoc/cordis.yml b/snapshots/session/cordis-inspect-jsdoc/cordis.yml index febc5da6c5..2da7998319 100644 --- a/snapshots/session/cordis-inspect-jsdoc/cordis.yml +++ b/snapshots/session/cordis-inspect-jsdoc/cordis.yml @@ -1,4 +1,4 @@ -# Add Code Mode and Cordis tools to the base spawn/workflow stack, exercising +# Add PTC mode and Cordis tools to the base spawn/workflow stack, exercising # all four boundaries in one ACP snapshot. - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' diff --git a/snapshots/session/code-mode-read-image/cordis.snapshot.yml b/snapshots/session/ptc-read-image/cordis.snapshot.yml similarity index 94% rename from snapshots/session/code-mode-read-image/cordis.snapshot.yml rename to snapshots/session/ptc-read-image/cordis.snapshot.yml index a187ccc673..fdf773028d 100644 --- a/snapshots/session/code-mode-read-image/cordis.snapshot.yml +++ b/snapshots/session/ptc-read-image/cordis.snapshot.yml @@ -1,4 +1,4 @@ -# Keyless replay combines Code Mode with the durable image store and an exact +# Keyless replay combines PTC mode with the durable image store and an exact # image-capable replay route. The scenario generates its tiny PNG inside the # run_code program, then exercises read_image as a nested dispatch. - id: llm-deepseek @@ -25,7 +25,7 @@ - id: tools name: '@deepseek-ai/dsh-tools' config: - mode: code + mode: ptc - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/code-mode-read-image/cordis.yml b/snapshots/session/ptc-read-image/cordis.yml similarity index 91% rename from snapshots/session/code-mode-read-image/cordis.yml rename to snapshots/session/ptc-read-image/cordis.yml index 8e469c69d1..01575986b5 100644 --- a/snapshots/session/code-mode-read-image/cordis.yml +++ b/snapshots/session/ptc-read-image/cordis.yml @@ -1,4 +1,4 @@ -# Code Mode image overlay: mounts the worker runtime and durable attachment +# PTC mode image overlay: mounts the worker runtime and durable attachment # store so a nested read_image result can cross the generic rich-result bridge. # The live config selects the shipped vision route for manual use. - id: agent-default-model @@ -21,7 +21,7 @@ - id: tools name: '@deepseek-ai/dsh-tools' config: - mode: code + mode: ptc - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/code-mode-read-image/session.jsonl b/snapshots/session/ptc-read-image/session.jsonl similarity index 100% rename from snapshots/session/code-mode-read-image/session.jsonl rename to snapshots/session/ptc-read-image/session.jsonl diff --git a/snapshots/session/ptc-read-image/snapshot.yml b/snapshots/session/ptc-read-image/snapshot.yml new file mode 100644 index 0000000000..e7a2fa6085 --- /dev/null +++ b/snapshots/session/ptc-read-image/snapshot.yml @@ -0,0 +1,12 @@ +version: 1 +scenario: ptc-read-image +profile: headless +composition: ptc-image +recording: authored +header: + class: ptc-image + pin: true + toolSchemasSource: ptc-turn +platform: posix +workspace: + final: true diff --git a/snapshots/session/code-mode-read-image/system-prompt.expected.md b/snapshots/session/ptc-read-image/system-prompt.expected.md similarity index 100% rename from snapshots/session/code-mode-read-image/system-prompt.expected.md rename to snapshots/session/ptc-read-image/system-prompt.expected.md diff --git a/snapshots/session/code-mode-read-image/workspace.expected/red.png b/snapshots/session/ptc-read-image/workspace.expected/red.png similarity index 100% rename from snapshots/session/code-mode-read-image/workspace.expected/red.png rename to snapshots/session/ptc-read-image/workspace.expected/red.png diff --git a/snapshots/session/code-mode-turn/cordis.snapshot.yml b/snapshots/session/ptc-turn/cordis.snapshot.yml similarity index 93% rename from snapshots/session/code-mode-turn/cordis.snapshot.yml rename to snapshots/session/ptc-turn/cordis.snapshot.yml index bce8a6a4d5..805d0c6ce8 100644 --- a/snapshots/session/code-mode-turn/cordis.snapshot.yml +++ b/snapshots/session/ptc-turn/cordis.snapshot.yml @@ -1,4 +1,4 @@ -# Keyless Code Mode combines the runtime/registry changes with the +# Keyless PTC mode combines the runtime/registry changes with the # DeepSeek-to-replay swap in one profile patch. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' @@ -24,7 +24,7 @@ - id: tools name: '@deepseek-ai/dsh-tools' config: - mode: code + mode: ptc - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/code-mode-turn/cordis.yml b/snapshots/session/ptc-turn/cordis.yml similarity index 91% rename from snapshots/session/code-mode-turn/cordis.yml rename to snapshots/session/ptc-turn/cordis.yml index 830ffe0bf0..46cbe0345a 100644 --- a/snapshots/session/code-mode-turn/cordis.yml +++ b/snapshots/session/ptc-turn/cordis.yml @@ -1,4 +1,4 @@ -# Code Mode adds `ctx.codeRuntime` and changes the registry to one wire tool, +# PTC mode adds `ctx.codeRuntime` and changes the registry to one wire tool, # `run_code`, plus its generated TypeScript SDK prompt. The demo and snapshot # recorder apply this profile patch; replay applies its sibling patch. - id: agent-default-model @@ -21,7 +21,7 @@ - id: tools name: '@deepseek-ai/dsh-tools' config: - mode: code + mode: ptc - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/code-mode-turn/session.jsonl b/snapshots/session/ptc-turn/session.jsonl similarity index 100% rename from snapshots/session/code-mode-turn/session.jsonl rename to snapshots/session/ptc-turn/session.jsonl diff --git a/snapshots/session/code-mode-turn/snapshot.yml b/snapshots/session/ptc-turn/snapshot.yml similarity index 53% rename from snapshots/session/code-mode-turn/snapshot.yml rename to snapshots/session/ptc-turn/snapshot.yml index f9be9378f0..82b648e356 100644 --- a/snapshots/session/code-mode-turn/snapshot.yml +++ b/snapshots/session/ptc-turn/snapshot.yml @@ -1,8 +1,8 @@ version: 1 -scenario: code-mode-turn +scenario: ptc-turn profile: headless -composition: code +composition: ptc recording: live header: - class: code + class: ptc pin: true diff --git a/snapshots/session/code-mode-turn/system-prompt.expected.md b/snapshots/session/ptc-turn/system-prompt.expected.md similarity index 100% rename from snapshots/session/code-mode-turn/system-prompt.expected.md rename to snapshots/session/ptc-turn/system-prompt.expected.md diff --git a/snapshots/session/code-mode-turn/tool-schemas.expected.json b/snapshots/session/ptc-turn/tool-schemas.expected.json similarity index 100% rename from snapshots/session/code-mode-turn/tool-schemas.expected.json rename to snapshots/session/ptc-turn/tool-schemas.expected.json diff --git a/snapshots/session/code-mode-workspace-context/cordis.snapshot.yml b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml similarity index 87% rename from snapshots/session/code-mode-workspace-context/cordis.snapshot.yml rename to snapshots/session/ptc-workspace-context/cordis.snapshot.yml index eb65aa20de..59292c9b9f 100644 --- a/snapshots/session/code-mode-workspace-context/cordis.snapshot.yml +++ b/snapshots/session/ptc-workspace-context/cordis.snapshot.yml @@ -1,5 +1,5 @@ -# Keyless replay counterpart of code-mode-workspace-context.cordis.yml. It adds -# Code Mode to the default filesystem suite and swaps in replay. +# Keyless replay counterpart of ptc-workspace-context.cordis.yml. It adds +# PTC mode to the default filesystem suite and swaps in replay. - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' disabled: true @@ -24,7 +24,7 @@ - id: tools name: '@deepseek-ai/dsh-tools' config: - mode: code + mode: ptc - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/code-mode-workspace-context/cordis.yml b/snapshots/session/ptc-workspace-context/cordis.yml similarity index 89% rename from snapshots/session/code-mode-workspace-context/cordis.yml rename to snapshots/session/ptc-workspace-context/cordis.yml index 11e7ef2b25..391e99f4f9 100644 --- a/snapshots/session/code-mode-workspace-context/cordis.yml +++ b/snapshots/session/ptc-workspace-context/cordis.yml @@ -1,4 +1,4 @@ -# Code Mode agent-instructions snapshot recording overlay. The default filesystem +# PTC mode agent-instructions snapshot recording overlay. The default filesystem # tools trigger nested instruction discovery after a read. - id: agent-default-model name: '@deepseek-ai/dsh-agent-default-model' @@ -20,7 +20,7 @@ - id: tools name: '@deepseek-ai/dsh-tools' config: - mode: code + mode: ptc - id: system-prompt name: '@deepseek-ai/dsh-system-prompt' diff --git a/snapshots/session/code-mode-workspace-context/replay.override.json b/snapshots/session/ptc-workspace-context/replay.override.json similarity index 100% rename from snapshots/session/code-mode-workspace-context/replay.override.json rename to snapshots/session/ptc-workspace-context/replay.override.json diff --git a/snapshots/session/code-mode-workspace-context/session.jsonl b/snapshots/session/ptc-workspace-context/session.jsonl similarity index 100% rename from snapshots/session/code-mode-workspace-context/session.jsonl rename to snapshots/session/ptc-workspace-context/session.jsonl diff --git a/snapshots/session/ptc-workspace-context/snapshot.yml b/snapshots/session/ptc-workspace-context/snapshot.yml new file mode 100644 index 0000000000..65874ba567 --- /dev/null +++ b/snapshots/session/ptc-workspace-context/snapshot.yml @@ -0,0 +1,12 @@ +version: 1 +scenario: ptc-workspace-context +profile: headless +composition: ptc-workspace-context +recording: authored +header: + class: ptc-workspace-context + pin: true + systemPromptSource: ptc-turn + toolSchemasSource: ptc-turn +replay: + override: true diff --git a/snapshots/session/code-mode-workspace-context/workspace/AGENTS.md b/snapshots/session/ptc-workspace-context/workspace/AGENTS.md similarity index 100% rename from snapshots/session/code-mode-workspace-context/workspace/AGENTS.md rename to snapshots/session/ptc-workspace-context/workspace/AGENTS.md diff --git a/snapshots/session/code-mode-workspace-context/workspace/nested/AGENTS.md b/snapshots/session/ptc-workspace-context/workspace/nested/AGENTS.md similarity index 100% rename from snapshots/session/code-mode-workspace-context/workspace/nested/AGENTS.md rename to snapshots/session/ptc-workspace-context/workspace/nested/AGENTS.md diff --git a/snapshots/session/code-mode-workspace-context/workspace/nested/task.txt b/snapshots/session/ptc-workspace-context/workspace/nested/task.txt similarity index 100% rename from snapshots/session/code-mode-workspace-context/workspace/nested/task.txt rename to snapshots/session/ptc-workspace-context/workspace/nested/task.txt diff --git a/snapshots/session/skill-load/session.jsonl b/snapshots/session/skill-load/session.jsonl index 06629e31f4..a0d21122d3 100644 --- a/snapshots/session/skill-load/session.jsonl +++ b/snapshots/session/skill-load/session.jsonl @@ -22,7 +22,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:4}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}},"sourceEventSeqs":[13,14,15,16,17,18,19,20],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"editing-cordis-compositions\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"{{message:5}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_skill_load"},"content":[{"type":"tool-result","toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}],"isError":false}],"role":"user","id":"{{message:5}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/snapshots/web/code-mode-round/snapshot.yml b/snapshots/web/code-mode-round/snapshot.yml deleted file mode 100644 index ac7b3c2285..0000000000 --- a/snapshots/web/code-mode-round/snapshot.yml +++ /dev/null @@ -1,8 +0,0 @@ -version: 1 -scenario: code-mode-round -profile: web -composition: web-code -recording: live -header: - class: web-code - pin: true diff --git a/snapshots/web/code-mode-round/session.jsonl b/snapshots/web/ptc-round/session.jsonl similarity index 100% rename from snapshots/web/code-mode-round/session.jsonl rename to snapshots/web/ptc-round/session.jsonl diff --git a/snapshots/web/ptc-round/snapshot.yml b/snapshots/web/ptc-round/snapshot.yml new file mode 100644 index 0000000000..01875305c8 --- /dev/null +++ b/snapshots/web/ptc-round/snapshot.yml @@ -0,0 +1,8 @@ +version: 1 +scenario: ptc-round +profile: web +composition: web-ptc +recording: live +header: + class: web-ptc + pin: true diff --git a/snapshots/web/code-mode-round/system-prompt.expected.md b/snapshots/web/ptc-round/system-prompt.expected.md similarity index 100% rename from snapshots/web/code-mode-round/system-prompt.expected.md rename to snapshots/web/ptc-round/system-prompt.expected.md diff --git a/snapshots/web/code-mode-round/tool-schemas.expected.json b/snapshots/web/ptc-round/tool-schemas.expected.json similarity index 100% rename from snapshots/web/code-mode-round/tool-schemas.expected.json rename to snapshots/web/ptc-round/tool-schemas.expected.json diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md similarity index 100% rename from snapshots/web/code-mode-round/ui.expected.md rename to snapshots/web/ptc-round/ui.expected.md diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index ce5851b7e9..51350c74e6 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -32,7 +32,7 @@ - button "Skill editing-cordis-compositions" [expanded]: - img - text: Skill editing-cordis-compositions -- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " +- region "Instructions": "Instructions Base directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions Resolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed. # Editing Cordis compositions Every capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it. ## Off-limits **Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `ptc`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation. To change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete. ## Decide the plane first Two planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared. **Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process. **Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it. **A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side. A preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name. Locally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created. ## The roster service `ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step. Read `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on: - `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent. - `read(id)` — one preset's composition text, without a file tool or a path. - `copy(from, id, name?)` — the only authoring write (see below). - `standingKeyFor(id)` — mount-validate one preset (see below). ```js return { name: 'preset-tools', inject: ['agentPresets', 'tools'], apply(ctx) { harness.registerTool(ctx, harness.defineTool({ name: 'preset_check', description: 'Mount-validate one preset by id.', parameters: { id: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } }, async execute(args) { try { await ctx.agentPresets.standingKeyFor(args.id) return 'mounted OK' } catch (error) { return error.message } }, })) }, } ``` Unmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind. ## Authoring a preset 1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source. 2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do. 3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule. 5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*. A composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable. ## The rule that catches people **A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later. Whether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service. When a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here: ```yaml - id: delegation name: cordis:group group: true isolate: workflows: true config: - id: workflow-worker-thread name: '@deepseek-ai/dsh-workflow-worker-thread' config: provider: spawn - id: tool-workflow name: '@deepseek-ai/dsh-tool-workflow' ``` `true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs. A consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated. Realms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm. ## Verifying a change **`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails: - a row whose package does not resolve (`Cannot find package …`); - a row whose config is invalid (`invalid config: $. missing required value`); - a row that never activated (`N row(s) did not activate: : waiting for `); - a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service. It returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind. **Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition. `cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do. After a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces. `cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file. ## Native product subagents Codex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers: ```sh dsh plugin --profile add @deepseek-ai/dsh-subagent-codex dsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code dsh plugin --profile remove @deepseek-ai/dsh-subagent-codex dsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code ``` Each Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start. Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: ```yaml - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: codex toolName: subagent_codex backgroundMode: one-shot maxDepth: provider-managed - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' disabled: true config: provider: claude-code toolName: subagent_claude_code backgroundMode: one-shot maxDepth: provider-managed ``` For additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings. The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings. ## What not to move into a preset `agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement. " - button "Inspect" - button "Think The skill is loaded.": - img diff --git a/tsconfig.host.json b/tsconfig.host.json index 14bfe0822f..561cee9b17 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -46,7 +46,7 @@ "apps/web/tests/sidebar-scrollbar.e2e.ts", "apps/web/tests/rail-search-expand.e2e.ts", "apps/web/tests/conversation-column-overflow.e2e.ts", - "apps/web/tests/code-mode-round.e2e.ts", + "apps/web/tests/ptc-round.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", "apps/web/tests/web-search-round.e2e.ts", From 45c514a42b8e3012775acedb9edef2bf22f6e990 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:08:52 +0800 Subject: [PATCH 119/130] fix: align mode-value prose and stale persistent mentions with the split The split kept the session-persistent vocabulary (tool/code-dispatch*, tools-code-mode, :code:) on this PR, but several prose surfaces still named the new values: the zh persistence/tool catalogs, the renamed Agent Notes' event mentions, spill-policy comments, and a garbled 're-enPTC mode' replacement. Also rename the mode value to ptc in the places the rename missed (tools and agent-tool-presentation READMEs, the Config JSDoc, note mode unions) and the codeModeHarness* e2e helpers. --- ...0-canonical-tool-output-contract.i18n.yaml | 4 ++-- ...26-07-20-canonical-tool-output-contract.md | 2 +- ...07-20-canonical-tool-output-contract.zh.md | 2 +- ...2026-08-07-ptc-executor-collapse.i18n.yaml | 4 ++-- .../2026-08-07-ptc-executor-collapse.md | 6 ++--- .../2026-08-07-ptc-executor-collapse.zh.md | 4 ++-- .../feature/2026-06-15-ptc.i18n.yaml | 4 ++-- .../implemented/feature/2026-06-15-ptc.md | 22 ++++++++--------- .../implemented/feature/2026-06-15-ptc.zh.md | 22 ++++++++--------- ...026-07-20-ptc-typed-tool-returns.i18n.yaml | 4 ++-- .../2026-07-20-ptc-typed-tool-returns.md | 2 +- .../2026-07-20-ptc-typed-tool-returns.zh.md | 2 +- ...2026-07-26-ptc-chat-subcall-rows.i18n.yaml | 4 ++-- .../2026-07-26-ptc-chat-subcall-rows.md | 4 ++-- .../2026-07-26-ptc-chat-subcall-rows.zh.md | 4 ++-- ...026-07-26-ptc-dispatch-log-spill.i18n.yaml | 4 ++-- .../2026-07-26-ptc-dispatch-log-spill.md | 4 ++-- .../2026-07-26-ptc-dispatch-log-spill.zh.md | 4 ++-- ...07-26-ptc-dispatch-ui-foundation.i18n.yaml | 4 ++-- .../2026-07-26-ptc-dispatch-ui-foundation.md | 6 ++--- ...026-07-26-ptc-dispatch-ui-foundation.zh.md | 6 ++--- ...07-26-ptc-live-parallel-dispatch.i18n.yaml | 4 ++-- .../2026-07-26-ptc-live-parallel-dispatch.md | 4 ++-- ...026-07-26-ptc-live-parallel-dispatch.zh.md | 4 ++-- .../2026-07-28-web-terminal-card.i18n.yaml | 4 ++-- .../feature/2026-07-28-web-terminal-card.md | 2 +- .../2026-07-28-web-terminal-card.zh.md | 2 +- ...026-07-30-web-read-card-frontend.i18n.yaml | 4 ++-- .../2026-07-30-web-read-card-frontend.md | 2 +- .../2026-07-30-web-read-card-frontend.zh.md | 2 +- .../tests/profiles/headless/tests/ptc.e2e.ts | 24 +++++++++---------- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 2 +- docs/persistence-catalog.zh.md | 8 +++---- docs/tool-catalog.i18n.yaml | 2 +- docs/tool-catalog.zh.md | 2 +- docs/tool-execution-pipeline.i18n.yaml | 2 +- docs/tool-execution-pipeline.zh.md | 2 +- .../core/agent-tool-presentation/src/index.ts | 2 +- packages/spill/spill-policy/src/index.ts | 4 ++-- 42 files changed, 101 insertions(+), 101 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml index 88da18837a..f509835eb1 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md -2026-07-20-canonical-tool-output-contract.md: 95f313732c78f4f9acf2a08ce492968e7942e2f2 -2026-07-20-canonical-tool-output-contract.zh.md: 39b7ea88df3c290bd8095fa05bf05497be262d74 +2026-07-20-canonical-tool-output-contract.md: 52b56a44a747a07e13e05511d6d40a85fcd9696d +2026-07-20-canonical-tool-output-contract.zh.md: a78defb2b45143d46fed2bedf760df12744621a5 diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md index 95f313732c..52b56a44a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` has two mutually exclusive successful projections. Replacing `content` changes only Native/model presentation and preserves the canonical value and metadata. Replacing `value` revalidates the replacement and recomputes both presentation projections. A block removes the value and becomes a failure. Content replacement is therefore not a confidentiality mechanism: policy that must prevent programmatic access blocks the call or replaces the value. -Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/ptc-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. +Canonical values are execution-local. The agent loop persists `tool/result` with only `content`, `error`, and optional `meta`; PTC mode's `tool/code-dispatch` persists the sub-call's rendered `content` and `isError`. Neither event stores the canonical intermediate value, so replay reproduces presentation but cannot reconstruct the programmatic result. When a tool declares `presentationMeta`, it is computed only for a direct surface call; a nested Code dispatch gets no metadata or result card. The outer `run_code` card instead reads final post-policy content and declares no presentation metadata. Generic and tool-owned spill projections similarly skip nested dispatches, whose canonical value never enters model context. The first-party tools preserve their existing Native text while returning domain DTOs: diff --git a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md index 39b7ea88df..a78defb2b4 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md @@ -34,7 +34,7 @@ type ToolExecutionResult = `tools/post-execute` 为成功结果提供两种互斥的投影方式。替换 `content` 只改变 Native/模型展示,并保留规范值和元数据。替换 `value` 会重新校验替代值,并重新计算两份展示投影。阻止操作会移除值并转为失败。因此,替换内容并不是保密机制:必须阻止程序化访问的策略,应当阻止调用或替换值。 -规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/ptc-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 +规范值仅存在于执行期间。agent loop(智能体循环)持久化的 `tool/result` 只包含 `content`、`error` 和可选的 `meta`;PTC mode 的 `tool/code-dispatch` 持久化子调用渲染后的 `content` 与 `isError`。两个事件都不存储规范中间值,因此回放可以重现展示,却无法重建程序化结果。当工具声明 `presentationMeta` 时,系统只会为直接的外层调用计算它;嵌套 Code 分发没有元数据或结果卡片。外层 `run_code` 卡片则读取最终的 post-policy 内容,并且不声明展示元数据。通用以及工具自有的 spill 投影同样跳过嵌套分发,因为它们的规范值永远不会进入模型上下文。 第一方工具在保持现有 Native 文本不变的同时返回领域 DTO: diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml index 5bd489c829..2c1974c57b 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.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-07-ptc-executor-collapse.md -2026-08-07-ptc-executor-collapse.md: 272748283a872662c3ee02276240303a5c7d54fa -2026-08-07-ptc-executor-collapse.zh.md: c65feb3aef6a471801cae1cdf3c49b7330b0951b +2026-08-07-ptc-executor-collapse.md: cbdbc916652f3b5b7ca339f3c856caae5f898f96 +2026-08-07-ptc-executor-collapse.zh.md: 9a6173198e17210985a77e34056291566dec93fa diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md index 906e3fc734..e034d30ff5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md @@ -12,11 +12,11 @@ The package contract names this exact anti-pattern: schema omission is not enfor ## Decision -`ToolRuntime` 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. +`ToolRuntime` 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 `ptc`, 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 [PTC mode foundation](../feature/2026-06-15-ptc.md) owns the transport design this note layers the execution boundary onto. +The collapse is a security-relevant invariant, so acceptance is pinned through the executor: a model-direct native call under `ptc` 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 [PTC mode foundation](../feature/2026-06-15-ptc.md) owns the transport design this note layers the execution boundary onto. ## Alternatives considered @@ -26,7 +26,7 @@ The view is consumed by presenters, `tool-cordis` inspection, and the SDK binder ### 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-enPTC mode semantics the registry already owns. +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-enable PTC mode semantics the registry already owns. ### Reject via a shipped guard diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md index 13dfe7b303..03f93a3235 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -`ToolRuntime` 通过新增的私有 `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 声明的全部绑定。 +`ToolRuntime` 通过新增的私有 `resolveExecution(name, scope, nested)` 解析可执行定义,在拥有该决策的操作边界上应用模式塌缩。当 `modeFor(scope)` 解析为 `ptc` 时,模型直呼(`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 把执行边界叠加在基础 [PTC mode 基础](../feature/2026-06-15-ptc.zh.md) 之上,传输设计由后者拥有。 +塌缩是安全相关的不变量,因此验收经执行器钉死:`ptc` 模式下模型直呼原生工具返回 `UNKNOWN_TOOL`;同一工具经 SDK 子调用成功;`native`/`both` 模式直呼与 `run_code` 本身行为不变。本 note 把执行边界叠加在基础 [PTC mode 基础](../feature/2026-06-15-ptc.zh.md) 之上,传输设计由后者拥有。 ## 备选方案 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml b/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml index 03bad269c4..ed42409d93 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.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-06-15-ptc.md -2026-06-15-ptc.md: 144ef987cf5a7c589237401a1adfec3cb6825504 -2026-06-15-ptc.zh.md: 24a6f813ec3eaa551c7e2ac8fcdd9762104de15b +2026-06-15-ptc.md: 5b3971c33df4be46c8a466e564ac86ba6454663a +2026-06-15-ptc.zh.md: fe44d8a0e97913998fc001c92f632f493888c720 diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.md b/.agents/notes/implemented/feature/2026-06-15-ptc.md index 144ef987cf..5b3971c33d 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.md @@ -18,7 +18,7 @@ Tool presentation belongs to the registry that owns tool visibility: implementin Three decisions, each elaborated in its own section below: -1. **PTC mode is a first-class presentation mode of `ToolRuntime`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'code'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. +1. **PTC mode is a first-class presentation mode of `ToolRuntime`** (`dsh-tools`), selected by a validated `mode` config: `'native'` (the default, contributing the visible capability schemas), `'ptc'` (the registry contributes only its reserved `run_code` transport plus a generated SDK `.d.ts` in the system prompt), or `'both'` (native schemas and the transport + SDK). The registry constructs its canonical contribution at the source; the cooperative prompt-assembly result remains authoritative, and the logged request header records exactly that returned presentation. 2. **Code execution is a capability seam** — `packages/code-runtime/` contains the Service Definition package `@deepseek-ai/dsh-code-runtime`, which owns `ctx.codeRuntime` ([capability seams](../architecture/2026-06-13-capability-seams.md); Consumer = `dsh-tools`, with core-consumes-a-seam precedent in `agent-loop` → `dsh-llm`). The runtime knows nothing about tools: it is handed a program and named async bindings, runs the program, and reports `{ value, logs, error? }`. Language and substrate are backend properties, so a future Python or container backend is another Service Provider package, not a redesign. 3. **The shipped implementation is `@deepseek-ai/dsh-code-runtime-worker-thread`**: one fresh Node worker thread per run, executing the model's TypeScript after type-strip, with bindings bridged over the message port, an empty environment, configurable heap/output/time caps, and hard termination. Its trust posture is bash-equivalent by design — no unsafe-acknowledgement flags — because the harness already ships `dsh-bash-local`, which executes arbitrary model-written shell commands with strictly *more* ambient authority. @@ -26,13 +26,13 @@ This note owns PTC mode's presentation, composition, isolation, and settlement f ### The registry owns the mode -`ToolRuntime` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'code' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: ptc }`) — no code edit, per the no-hardcoded-tunables convention. +`ToolRuntime` gains a schemastery-validated config (`static Config`), its first: `mode: 'native' | 'ptc' | 'both'`, default `'native'`. A deployment flips it from `cordis.yml` (`tools: { mode: ptc }`) — no code edit, per the no-hardcoded-tunables convention. -**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'code'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. +**Wire tool list.** The registry contributes visible capabilities in `'native'`, only `run_code` in `'ptc'`, and both in `'both'`. The final `PromptAssembly.tools` list is logged in the request header. `run_code` is a reserved presentation transport outside registration and restriction layers; direct prompt providers and the assembly waterfall remain responsible for their own contributions. **Interaction with `toolOrder`:** a configured `systemPrompt.toolOrder` naming native capabilities rejects every assembly under `mode: 'ptc'`, because those names are outside that mode's wire-validation universe. This is correct behavior, not a bug: a deployment using PTC mode updates its order config or drops it. -**SDK prompt section.** In `'code'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. +**SDK prompt section.** In `'ptc'` and `'both'`, the lazy `tools:sdk` section in the tool-guidance order band renders the loaded runtime's language declarations plus fixed usage instructions for the scope's visible capabilities (TypeScript by default; the [language-dispatch note](2026-07-31-ptc-language-dispatch.md) added Python and the `ctx.codeRuntime.language` renderer table). It shares lookup and execution visibility, excludes `run_code`, and sorts tools lexicographically for byte-stable output. **Assembly ownership.** `run_code` and `tools:sdk` enter the trusted `system-prompt/assemble` waterfall as normal assembly inputs. A scoped `tools:sdk` section may shadow the global default before dispatch, and a listener may remove or replace either contribution. The waterfall's returned assembly is final, so whoever changes these inputs owns preserving a viable PTC mode protocol when the deployment expects PTC mode to remain usable; no restoration pass overrides deliberate composition. @@ -40,9 +40,9 @@ This note owns PTC mode's presentation, composition, isolation, and settlement f ### The run_code tool and the dispatch bridge -Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove PTC mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: +Under `'ptc'` and `'both'` the registry owns `run_code` as a reserved presentation transport with two required parameters, `{ code: string; description: string }` (the description labels the call in UIs, the bash precedent). It is represented by a normal `ToolDefinition` for dispatch but stays outside the filterable capability layers, so restrictions cannot accidentally remove PTC mode's only entry point. Calls traverse the complete tool pipeline — `tools/pre-execute` → monotonic guards → `tools/execute` around dispatch → `tools/post-execute` → optional definition-owned `finalizeContent` → immutable `tools/result` notification — exactly like native calls; a permission plugin can inspect the program text before it runs, and final-result observers see the normalized outer outcome. Its `execute(args, exec)`: -1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/ptc-dispatch-start`/`tool/ptc-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. +1. **Build bindings.** One run-scoped signal follows outer cancellation and is aborted whenever the run settles. Each visible tool binding snapshots lossless-JSON arguments, enters the native-contract dispatch pool (the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) owns the scheduling design), executes with a deterministic call id and the outer token as `parent`, defers returned contexts through the outer execution, and logs the `tool/code-dispatch-start`/`tool/code-dispatch` pair, the settle side carrying the full rendered result content. Success returns the tool's final canonical JSON value; failure becomes the program-visible `ToolCallError`. Every sub-call retains its own immutable execution identity and traverses the full tool pipeline. 2. **Runs the program**: `ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`. The runtime receives the run-scoped signal, not only the caller's outer signal, so any way the outer run settles also aborts work inside the runtime. 3. **Settle after quiescence.** When the runtime settles, the bridge aborts outstanding work and drains the dispatch queue before returning. Success returns captured logs and the completion value as canonical output; the registry renders that value into durable `tool/result.content`, which the result card reads directly. A runtime failure becomes `CodeRunFailedError`; backend rejection uses the registry's normal error boundary. Both produce structured error results, and no sub-call can append after `run_code` settles. @@ -52,9 +52,9 @@ Under `'code'` and `'both'` the registry owns `run_code` as a reserved presentat **Presentation.** `run_code`'s render intent is decided here per the [render-intent Agent Note](../architecture/2026-07-02-tool-render-intent-union.md): `presentCall` creates a `generic` card with `kind: 'execute'`, the program text as its title, and the same program text as `rawInput`; `run_code` intentionally declares no `presentResult`, so the TUI and host/client runtime (Web) complete that card through their generic raw-content fallback using the final durable `tool/result.content`, including captured logs plus the returned value, failure, or post-policy spill preview. This is not a `terminal` card: that card's semantics are "a shell command in a working directory", which a program is not. See the [result-card completeness note](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md). -### Observability: `tool/ptc-dispatch` +### Observability: `tool/code-dispatch` -Each sub-dispatch appends a log-only `tool/ptc-dispatch-start` event at pool entry and a `tool/ptc-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. +Each sub-dispatch appends a log-only `tool/code-dispatch-start` event at pool entry and a `tool/code-dispatch` settle event containing parent and child call ids, tool identity, normalized arguments, and the complete rendered `content`/`isError` outcome. It remains outside model history but available to persistence and UIs. Appends occur inside the open `run_code` turn. Direct executions without an agent still run but cannot log the event. ### The code-runtime seam @@ -91,7 +91,7 @@ The transport's own `description` and both SDK instruction flavors open by namin ## Consequences -Deployments switching to `'code'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol messages. Sub-dispatch starts in submission order under a bounded overlap pool, while per-call contexts retain their source, envelope, and metadata through the outer result. +Deployments switching to `'ptc'` must update any native-only `toolOrder`. Assembly listeners own the integrity of any rewritten protocol messages. Sub-dispatch starts in submission order under a bounded overlap pool, while per-call contexts retain their source, envelope, and metadata through the outer result. ## Testing @@ -110,9 +110,9 @@ Deployments switching to `'code'` must update any native-only `toolOrder`. Assem **Parallel native dispatch in the loop.** The other answer to round-trip cost at decision time; it was blocked on concurrency-safety metadata and offers no composition either way — it parallelizes calls the model already decided on in one step. PTC mode's queue decision kept the two compatible, and both later shipped: the metadata as `isConcurrencySafe` (the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md)), and native rolling-pool dispatch plus per-tool binding parallelism on the same classifier. -**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'code'`) one line away without imposing it. +**Always-exclusive (Cloudflare-faithful, no mode).** Rejected for this SDK's primary consumer: a coding agent's bread-and-butter single calls (`bash`, `read`, `edit`) are already ideal as native calls, and forcing every edit through a program taxes the common case. The mode config keeps the faithful form (`'ptc'`) one line away without imposing it. -**Per-tool visibility tiers (this tool native, that tool code-only).** Deferred: it needs per-tool metadata and a presentation split that `'native' | 'code' | 'both'` does not, and its design depends on evidence about how models split usage under `'both'`. +**Per-tool visibility tiers (this tool native, that tool ptc-only).** Deferred: it needs per-tool metadata and a presentation split that `'native' | 'ptc' | 'both'` does not, and its design depends on evidence about how models split usage under `'both'`. **Sanitized identifier aliases in the SDK** (`my-tool` → `my_tool`, Cloudflare's approach). Rejected: quoted keys on a `declare const` make every name reachable with zero alias-collision logic; models handle `tools["my-tool"](…)` fine. diff --git a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md index 24a6f813ec..fe44d8a0e9 100644 --- a/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md +++ b/.agents/notes/implemented/feature/2026-06-15-ptc.zh.md @@ -18,7 +18,7 @@ Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替 三项决策,各自在下方独立小节中展开: -1. **PTC mode 是 `ToolRuntime`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'code'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 +1. **PTC mode 是 `ToolRuntime`(`dsh-tools`)的一等呈现模式**,通过经校验的 `mode` 配置选择:`'native'`(默认,贡献可见能力 schema)、`'ptc'`(注册表仅贡献其保留的 `run_code` 传输通道加一份生成的 SDK `.d.ts` 到系统提示词中)或 `'both'`(原生 schema 加传输通道 + SDK)。注册表在源头构建其规范贡献;协作式提示词组装的结果仍具权威性,记录在日志中的请求头精确反映该返回的呈现。 2. **代码执行是一个能力 seam**——`packages/code-runtime/` 包含 Service Definition 包 `@deepseek-ai/dsh-code-runtime`,拥有 `ctx.codeRuntime`([能力 seam](../architecture/2026-06-13-capability-seams.zh.md);消费方 = `dsh-tools`,core 消费 seam 的先例见 `agent-loop` → `dsh-llm`)。运行时对工具一无所知:它接收一段程序和命名的异步绑定,执行程序,报告 `{ value, logs, error? }`。语言和基底是后端属性,因此未来的 Python 或容器后端只是另一个 Service Provider 包,而非重新设计。 3. **交付的实现是 `@deepseek-ai/dsh-code-runtime-worker-thread`**:每次运行 spawn 一个全新的 Node worker 线程,对模型的 TypeScript 进行 type-strip 后执行,绑定通过消息端口桥接,环境为空,堆/输出/时间上限可配置,并支持硬终止。其信任姿态在设计上等同于 bash——无需 unsafe-acknowledgement flag——因为 harness 已经交付了 `dsh-bash-local`,后者以严格*更高*的环境权限执行模型编写的任意 shell 命令。 @@ -26,13 +26,13 @@ Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替 ### 注册表拥有模式 -`ToolRuntime` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'code' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: ptc }`),无需改代码,遵循 no-hardcoded-tunables 约定。 +`ToolRuntime` 获得一个经 schemastery 校验的配置(`static Config`),这是它的第一个配置:`mode: 'native' | 'ptc' | 'both'`,默认 `'native'`。部署通过 `cordis.yml` 翻转模式(`tools: { mode: ptc }`),无需改代码,遵循 no-hardcoded-tunables 约定。 -**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'code'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。 +**协议工具列表。** 注册表在 `'native'` 下贡献可见能力,在 `'ptc'` 下仅贡献 `run_code`,在 `'both'` 下两者都贡献。最终的 `PromptAssembly.tools` 列表记录在请求头中。`run_code` 是一个保留的呈现传输通道,位于注册和限制层之外;直接提示词提供方和组装 waterfall 仍各自负责自己的贡献。 **与 `toolOrder` 的交互:** 如果配置的 `systemPrompt.toolOrder` 引用了原生能力名称,在 `mode: 'ptc'` 下会拒绝所有组装,因为那些名称不在该模式的协议校验范围内。这是正确行为而非 bug:使用 PTC mode 的部署需要更新其 order 配置或移除它。 -**SDK 提示词段。** 在 `'code'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 +**SDK 提示词段。** 在 `'ptc'` 和 `'both'` 下,tool-guidance order band 中的惰性 `tools:sdk` 段为当前 scope 的可见能力渲染所加载运行时语言的声明加固定的使用说明(默认 TypeScript;[语言分发 note](2026-07-31-ptc-language-dispatch.zh.md) 加入了 Python 与按 `ctx.codeRuntime.language` 选择的渲染器表)。它共享查找和执行可见性,排除 `run_code`,并按字典序排列工具以获得字节稳定的输出。 **组装所有权。** `run_code` 和 `tools:sdk` 作为正常的组装输入进入受信任的 `system-prompt/assemble` waterfall。一个 scoped 的 `tools:sdk` 段可以在分发前遮蔽全局默认值,监听器也可以移除或替换任一贡献。waterfall 返回的组装结果是最终的,因此修改这些输入的人有责任在部署期望 PTC mode 可用时保持协议面的完整性;没有恢复 pass 会覆盖有意的组合。 @@ -40,9 +40,9 @@ Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替 ### run_code 工具与分发桥 -在 `'code'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 PTC mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: +在 `'ptc'` 和 `'both'` 下,注册表拥有 `run_code` 作为保留的呈现传输通道,带两个必需参数 `{ code: string; description: string }`(description 为 UI 标注该调用,沿用 bash 的先例)。它由一个正常的 `ToolDefinition` 表示以供分发,但位于可过滤的能力层之外,因此限制规则不会意外移除 PTC mode 的唯一入口。调用遍历完整的工具流水线——`tools/pre-execute` → 单调性守卫 → `tools/execute` 包裹分发 → `tools/post-execute` → 由定义拥有的可选 `finalizeContent` → 不可变的 `tools/result` 通知——与原生调用完全一致;权限插件可以在程序运行前检查程序文本,最终结果观察者看到的是规范化的外层结果。其 `execute(args, exec)`: -1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/ptc-dispatch-start`/`tool/ptc-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 +1. **构建绑定。** 一个 run 级别的 signal 跟随外层取消,并在 run 结算时被 abort。每个可见工具绑定都会对无损 JSON 参数创建快照,进入原生约定的分发池(调度设计由[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 负责),以确定性的 call id 和外层 token 作为 `parent` 执行,通过外层 execution 延后返回的上下文,并记录 `tool/code-dispatch-start`/`tool/code-dispatch` 事件对,其中结算侧携带完整渲染后的结果内容。成功时返回工具最终的规范 JSON 值;失败则变为程序可见的 `ToolCallError`。每个子调用保留自己不可变的执行标识,并遍历完整的工具流水线。 2. **运行程序**:`ctx.codeRuntime.run({ program: args.code, bindings: [{ global: 'tools', functions }], signal: runController.signal })`。运行时接收的是 run 级别的 signal 而非仅调用方的外层 signal,因此外层 run 以任何方式结算都会同时 abort 运行时内部的工作。 3. **完全停稳后结算。** 运行时结算后,桥 abort 未完成的工作并排空分发队列后再返回。成功时返回捕获的日志和完成值,将其作为规范输出;注册表再把该值渲染为持久化的 `tool/result.content`,供结果卡片直接读取。运行时失败变为 `CodeRunFailedError`;后端拒绝使用注册表的正常错误边界。两者都产生结构化的错误结果,且 `run_code` 结算后不允许子调用追加。 @@ -52,9 +52,9 @@ Cloudflare 的 [PTC mode](https://blog.cloudflare.com/ptc/) 提出了一种替 **呈现。** `run_code` 的 render intent 按[呈现意图 Agent Note](../architecture/2026-07-02-tool-render-intent-union.zh.md)在此决定:`presentCall` 创建一个 `generic` 卡片,`kind: 'execute'`,以程序文本作为标题,并将同一程序文本作为 `rawInput`;`run_code` 有意不声明 `presentResult`,因此 TUI 和宿主/客户端运行时(Web)会通过通用原始内容回退机制,使用最终持久化的 `tool/result.content` 补全该卡片,其中包括捕获的日志,以及返回值、失败信息或 post-policy spill 预览。这不是 `terminal` 卡片:该卡片的语义是「工作目录中的 shell 命令」,程序不是。参见[结果卡片完整性说明](../../archived/bug-fix/2026-07-20-code-mode-result-card-completeness.md)。 -### 可观测性:`tool/ptc-dispatch` +### 可观测性:`tool/code-dispatch` -每次子分发在进入分发池时追加一个仅日志的 `tool/ptc-dispatch-start` 事件,并以一个 `tool/ptc-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 +每次子分发在进入分发池时追加一个仅日志的 `tool/code-dispatch-start` 事件,并以一个 `tool/code-dispatch` 结算事件收尾,后者包含父子 call id、工具标识、规范化参数以及完整渲染后的 `content`/`isError` 结果。它不进入模型历史,但可供持久化和 UI 使用。追加发生在开放的 `run_code` 轮次内。没有 agent 的直接执行仍然运行,但无法记录该事件。 ### code-runtime seam @@ -91,7 +91,7 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 ## 后果 -切换到 `'code'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议消息的完整性。子分发在有界的重叠池下按提交顺序启动,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 +切换到 `'ptc'` 的部署必须更新任何仅限 native 的 `toolOrder`。组装监听器有责任维护任何被重写的协议消息的完整性。子分发在有界的重叠池下按提交顺序启动,而每次调用的上下文会通过外层结果保留其 source、信封与元数据。 ## 测试 @@ -110,9 +110,9 @@ SDK 指示模型编写一个所加载运行时语言的异步函数体(默认 **循环中的并行原生分发。** 决策当时对往返成本的另一个答案;它被并发安全元数据阻塞,且无论如何都不提供组合能力——它并行化的是模型在一步中已经决定的调用。PTC mode 的队列决策保持了两者兼容,两者后来都已交付:元数据即 `isConcurrencySafe`(见[并行工具调用 note](2026-07-10-parallel-tool-call-execution.zh.md)),原生 rolling-pool 分发加每工具绑定并行化则基于同一个分类器。 -**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'code'`)只需一行配置即可启用,而不强加于人。 +**始终排他(忠于 Cloudflare,无模式)。** 否决,因为本 SDK 的主要消费方是编码 agent:其日常的单次调用(`bash`、`read`、`edit`)作为原生调用已经是最优的,强制每次编辑都通过程序会给常见场景增加负担。mode 配置让忠实形式(`'ptc'`)只需一行配置即可启用,而不强加于人。 -**每工具可见性分层(此工具 native,彼工具 code-only)。** 推迟:它需要每工具元数据和 `'native' | 'code' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。 +**每工具可见性分层(此工具 native,彼工具 ptc-only)。** 推迟:它需要每工具元数据和 `'native' | 'ptc' | 'both'` 不提供的呈现拆分,且其设计取决于模型在 `'both'` 下如何分配使用的证据。 **SDK 中的清洁化标识符别名**(`my-tool` → `my_tool`,Cloudflare 的做法)。否决:`declare const` 上的带引号键使每个名称可达,零别名碰撞逻辑;模型能正常处理 `tools["my-tool"](…)`。 diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml index 249e799554..8ef13a4638 100644 --- a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.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-ptc-typed-tool-returns.md -2026-07-20-ptc-typed-tool-returns.md: 113ea2c305984ce95b0d85d70f6773abbc0929db -2026-07-20-ptc-typed-tool-returns.zh.md: 13b2f6e381a30b4db1cd818ab47d3e30c8e0c84a +2026-07-20-ptc-typed-tool-returns.md: a4651eef89d32cdc2330eb5a2562bd34d617a3df +2026-07-20-ptc-typed-tool-returns.zh.md: d8e68e273bd18fe60d8ba8e0bbf435dbb773a9c2 diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md index 113ea2c305..a4651eef89 100644 --- a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.md @@ -73,7 +73,7 @@ Temporary Cordis Plugins follow the same rule: `cordis_mount` returns `{ id, plu ### Persistence, metadata, and spill -Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/ptc-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values. +Nested dispatch logs the sub-call's full rendered `content`/`isError` on `tool/code-dispatch` but does not persist canonical values. `tool/result` continues to persist only rendered content, error, and optional metadata. A successful final content sequence containing an image is also wrapped in a source-attributed user message and deferred through the outer result; the normal session event makes that model-visible input reconstructable. `SESSION_FORMAT_VERSION` remains unchanged (pre-release shape churn does not bump it) and replay cannot recreate intermediate canonical program values. The opaque `exec.parent` token marks nested calls. Presentation metadata and generic or tool-owned spill projections skip those calls because they have no direct result card and their canonical values never enter context. The outer `run_code` call alone produces one card and may spill its final post-policy presentation; `run_code` intentionally declares neither a result presenter nor presentation metadata, so UI adapters complete the card through their generic raw-content fallback using durable `tool/result.content`. diff --git a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md index 13b2f6e381..d8e68e273b 100644 --- a/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-ptc-typed-tool-returns.zh.md @@ -73,7 +73,7 @@ PTC mode 通过运行时请求中的 `{ name: "ToolCallError", memberNamePropert ### 持久化、元数据与 spill -嵌套分发在 `tool/ptc-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。 +嵌套分发在 `tool/code-dispatch` 上记录子调用完整渲染后的 `content`/`isError`,但不会持久化规范值。`tool/result` 继续只持久化渲染后的内容、错误和可选元数据。包含图片的成功最终内容序列还会包装成带来源归属的用户消息,并经外层结果延后;普通会话事件使该模型可见输入可以重建。`SESSION_FORMAT_VERSION` 保持不变(预发布阶段的形状变动不递增版本号),回放也无法重建程序的规范中间值。 不透明的 `exec.parent` token 用于标识嵌套调用。由于这些调用没有直接对应的结果卡片,而且其规范值永远不会进入上下文,展示元数据以及通用或工具自有的 spill 投影都会跳过它们。只有外层 `run_code` 调用会生成一张卡片,并且可能对 post-policy 处理后的最终展示执行 spill;`run_code` 有意既不声明结果展示器,也不声明展示元数据,因此 UI 适配器会通过通用的原始内容回退机制,使用持久化的 `tool/result.content` 补全该卡片。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml index 5b72a7564f..7d73b82e39 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.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-ptc-chat-subcall-rows.md -2026-07-26-ptc-chat-subcall-rows.md: ab0d796e5c44d4edefaf7be709c272a756043107 -2026-07-26-ptc-chat-subcall-rows.zh.md: c4dfc72a8f29de8c0eaf6b80e0788a6773df3d11 +2026-07-26-ptc-chat-subcall-rows.md: 5ddbd56f75acd4c0d6de70708e5c8f810e797e83 +2026-07-26-ptc-chat-subcall-rows.zh.md: 499742d488860db0d987387cbd67be645922aa7e diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md index ab0d796e5c..5ddbd56f75 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-ptc-chat-subcall-rows.zh.md) -> Scope: how the web chat view renders a `run_code` turn — the client-side half of the PTC mode UI stack, built on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) (full-content `tool/ptc-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. +> Scope: how the web chat view renders a `run_code` turn — the client-side half of the PTC mode UI stack, built on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) (full-content `tool/code-dispatch`, the required `description` parameter). The [toolview dissolution](../architecture/2026-07-23-toolview-dissolution.md) owns the slot model this rides on. ## Problem @@ -14,7 +14,7 @@ With PTC mode enabled, the chat view showed one opaque `run_code` row: raw progr **Sub-calls are standard Tool call blocks attached recursively to their parent outside the surface flow, rendered through the same keyed slot as native rows, and always visible under their parent.** -- **Data layer**: Runtime's `ToolCallTree` folds in-window `tool/ptc-dispatch-start` and `tool/ptc-dispatch` events into a private per-parent index, then projects running and settled children onto recursive `ToolCallBlock.subCalls`. Live Session projection and `projectConversationHistory` share that fold; copy-on-write parent arrays and path-copy projection keep unrelated roots and siblings reference-stable. Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The events are narrowed structurally at the wire-consumer boundary, which also rejects cyclic parent relationships (dsh-tools' host types cannot enter the client program because the host/client `Context` merges collide). +- **Data layer**: Runtime's `ToolCallTree` folds in-window `tool/code-dispatch-start` and `tool/code-dispatch` events into a private per-parent index, then projects running and settled children onto recursive `ToolCallBlock.subCalls`. Live Session projection and `projectConversationHistory` share that fold; copy-on-write parent arrays and path-copy projection keep unrelated roots and siblings reference-stable. Sub-calls never join `nodes` — the surface flow remains exactly the model-visible turn structure. The events are narrowed structurally at the wire-consumer boundary, which also rejects cyclic parent relationships (dsh-tools' host types cannot enter the client program because the host/client `Context` merges collide). - **Render layer**: `ChatView` passes each parent with its recursive children through the whole-Tool `'conversation.chat.tool'` seat. ui-tool's `ToolCallTree` renders the parent followed by `[data-subcalls]` nests, and every atomic call dispatches through the same `'tool.call.toolview'` keyed slot with `entryKey = Tool name` and the same `GenericToolCard` fallback. A keyed registration therefore takes over descendant and top-level calls without registration changes. Running parents (`runningCalls`) receive accumulated dispatches in the same recursive block, so child rows stream in during the run. - **`run_code` presentation**: a new `code` row variant (classifier `run_code → code`, `Code` title, `IconCodeOutline16`) summarizes with the model-authored `description` and expands to the program itself (monospace on the markdown code-block fill) rather than the args JSON envelope. - **Details panel**: `materialFor` recursively searches `nodes` and `runningCalls`, so a selected descendant callId resolves to full args and complete output through the identical rendering path as a native settled call. diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md index c4dfc72a8f..499742d488 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-chat-subcall-rows.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-ptc-chat-subcall-rows.md) | 中文 -> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 PTC mode UI 栈的客户端侧部分,构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)之上(携带完整内容的 `tool/ptc-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.zh.md)所有。 +> 范围:Web chat 视图如何渲染一个 `run_code` 轮次,即 PTC mode UI 栈的客户端侧部分,构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)之上(携带完整内容的 `tool/code-dispatch`、必填的 `description` 参数)。本篇所依托的 slot 模型归 [toolview 溶解](../architecture/2026-07-23-toolview-dissolution.zh.md)所有。 ## 问题 @@ -14,7 +14,7 @@ Status: implemented **子调用是在 surface 流之外递归附着到父级的标准工具调用块,经由与原生行相同的 keyed slot 渲染,并始终显示在父级之下。** -- **数据层**:运行时的 `ToolCallTree` 把窗口内的 `tool/ptc-dispatch-start` 与 `tool/ptc-dispatch` 事件折入私有的逐父级索引,再把运行中和已结算的子级投影到递归的 `ToolCallBlock.subCalls` 上。实时会话投影与 `projectConversationHistory` 共享这一折叠过程;逐父级的写时复制数组和路径复制投影让无关根节点与兄弟节点保持引用稳定。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。这些事件在 wire 消费方边界作结构性收窄,该边界也会拒绝成环的父子关系(dsh-tools 的宿主类型无法进入客户端程序,因为宿主端与客户端两侧的 `Context` 声明合并会冲突)。 +- **数据层**:运行时的 `ToolCallTree` 把窗口内的 `tool/code-dispatch-start` 与 `tool/code-dispatch` 事件折入私有的逐父级索引,再把运行中和已结算的子级投影到递归的 `ToolCallBlock.subCalls` 上。实时会话投影与 `projectConversationHistory` 共享这一折叠过程;逐父级的写时复制数组和路径复制投影让无关根节点与兄弟节点保持引用稳定。子调用永不进入 `nodes`——surface 流始终精确等于模型可见的轮次结构。这些事件在 wire 消费方边界作结构性收窄,该边界也会拒绝成环的父子关系(dsh-tools 的宿主类型无法进入客户端程序,因为宿主端与客户端两侧的 `Context` 声明合并会冲突)。 - **渲染层**:`ChatView` 通过整体工具 seat `'conversation.chat.tool'` 传递每个父调用及其递归子调用。ui-tool 的 `ToolCallTree` 先渲染 parent,再渲染 `[data-subcalls]` 嵌套;每个原子调用都通过同一个 `'tool.call.toolview'` keyed slot,以工具名称作为 `entryKey`,并共用 `GenericToolCard` fallback。一个 keyed 注册因此无需变化即可同时接管任意后代与顶层调用。运行中的 parent(`runningCalls`)在同一个递归块中接收已累积的 dispatch,使 child 行在运行期间实时流入。 - **`run_code` 的呈现**:新增一种 `code` 行变体(分类器映射 `run_code → code`、标题 `Code`、图标 `IconCodeOutline16`),以模型撰写的 `description` 作摘要,展开后显示程序本身(在 markdown 代码块的填充底色上以等宽字体呈现),而非参数的 JSON 封装。 - **详情面板**:`materialFor` 递归搜索 `nodes` 与 `runningCalls`,因此被选中的后代 callId 会经由与已完结的原生调用完全相同的渲染路径,解析出完整参数与完整输出。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml index 385bfcfaaf..d30a6637f7 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.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-ptc-dispatch-log-spill.md -2026-07-26-ptc-dispatch-log-spill.md: ca88e751bada63229f658907812742175f523137 -2026-07-26-ptc-dispatch-log-spill.zh.md: b115e746c01b5c22cee42ed14e6bdb1c4409694c +2026-07-26-ptc-dispatch-log-spill.md: fa99ceb0b0e8eb078ef29438246cf0d89370bea9 +2026-07-26-ptc-dispatch-log-spill.zh.md: ab115ba0927b6a62d838ca13d5c442e4ae485021 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md index ca88e751ba..fa99ceb0b0 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-ptc-dispatch-log-spill.zh.md) -> Scope: limiting the `tool/ptc-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-ptc-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) defines the event pair that this listener processes. +> Scope: limiting the `tool/code-dispatch` event's content with the existing spill implementation. The [host foundation note](2026-07-26-ptc-dispatch-ui-foundation.md) deliberately accepted the unlimited log and deferred spill support to this change; the [live-parallel note](2026-07-26-ptc-live-parallel-dispatch.md) defines the event pair that this listener processes. ## Problem @@ -14,7 +14,7 @@ After full-content dispatch logging was added, a `run_code` program that reads a **A `tools/ptc-dispatch-log` waterfall on the registry, with spill policy as its first listener.** -- **Extension point**: `tools/ptc-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/ptc-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `PtcDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. +- **Extension point**: `tools/ptc-dispatch-log` is a scope-filtered waterfall that the bridge runs over each settled sub-dispatch before appending `tool/code-dispatch`. The bridge receives the registry's private `shapeDispatchLog` invoker as a capability closure in `RunCodeBridgeOptions`; the waterfall is the public contract, and the invoker does not add a service method. If a listener throws, the invoker reports any thrown value safely and uses the original settled content. The `PtcDispatchLog` payload carries the outer execution, the `agent` routing key, the sub-call identity, and the default content: the rendered result projection that a native `tool/result` would carry, while the program receives the structured `value`. A listener can replace only the durable copy, which the model never sees. The listener runs as tracked work outside the program's result path. When more than `maxParallelSubCalls` log tasks are pending, the ordered commit loop waits, so a slow spill backend limits later sub-call starts instead of accumulating unlimited pending I/O. Run settlement still waits for every task inside the open turn. - **Policy**: `dsh-spill-policy` registers a listener for this event and uses the same replacement code as its model-result listener: the same `maxInlineBytes` limit, preview and locator, within-limit invariant, and best-effort fallback. The spill artifact is labeled `dispatch` under the sub-call id. UIs and replay read its full text through the same path used for spilled native results, so both result kinds render with the same information. - **One deliberate difference**: the model-result listener skips `read` to prevent a `read → spill → read again` loop. The dispatch-log listener also replaces oversized `read` sub-call content because a log copy is not model context, so that loop cannot occur, and `read` is the tool most likely to produce a large log entry. diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md index b115e746c0..ab115ba092 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-ptc-dispatch-log-spill.md) | 中文 -> 范围:用既有的 spill 实现限制 `tool/ptc-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-ptc-dispatch-ui-foundation.zh.md) 有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 定义了该监听器处理的事件对。 +> 范围:用既有的 spill 实现限制 `tool/code-dispatch` 事件的内容。[宿主侧基础 Agent Note](2026-07-26-ptc-dispatch-ui-foundation.zh.md) 有意接受了不设上限的日志,并把 spill 支持留到本次更改;[实时并行 Agent Note](2026-07-26-ptc-live-parallel-dispatch.zh.md) 定义了该监听器处理的事件对。 ## 问题 @@ -14,7 +14,7 @@ Status: implemented **在注册表上增设 `tools/ptc-dispatch-log` waterfall(瀑布式事件),spill 策略作为其第一个监听器。** -- **扩展点**:`tools/ptc-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/ptc-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 以能力闭包形式接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`PtcDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会等待开放轮次内的全部任务完成。 +- **扩展点**:`tools/ptc-dispatch-log` 是一个按作用域过滤的 waterfall,桥接层会在追加 `tool/code-dispatch` 之前,对每个已结算的子分发运行它。桥接层通过 `RunCodeBridgeOptions` 以能力闭包形式接收注册表私有的 `shapeDispatchLog` 调用器;waterfall 是公开约定,该调用器不会增加服务方法。监听器抛出异常时,调用器会安全地报告任意抛出值,并使用原始的已结算内容。`PtcDispatchLog` 载荷包含外层执行、`agent` 路由键、子调用标识和默认内容;默认内容是原生 `tool/result` 会携带的渲染后结果投影,而程序收到结构化 `value`。监听器只能替换持久化副本,模型不会看到这份副本。监听器作为受跟踪任务在程序的返回路径之外运行。待处理日志任务超过 `maxParallelSubCalls` 时,有序提交循环会等待,因此慢速 spill 后端会限制后续子调用启动,而不会无限累积待完成 I/O。run 结算仍会等待开放轮次内的全部任务完成。 - **策略**:`dsh-spill-policy` 为该事件注册监听器,并复用面向模型结果的监听器所用的替换代码:相同的 `maxInlineBytes` 上限、预览和定位符、不超上限不变式,以及尽力而为回退。spill 产物以 `dispatch` 为标签,记录在子调用 id 名下。UI 与回放通过被 spill 的原生结果所用的同一路径读取全文,因此两类结果会渲染出相同的信息。 - **一处有意差异**:面向模型结果的监听器跳过 `read`,以防出现 `read → spill → read again` 循环。分发日志监听器也会替换过大的 `read` 子调用内容,因为日志副本不是模型上下文,该循环不会发生,而 `read` 最可能产生巨大的日志条目。 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml index deef5e0a47..e5ffbfea2f 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-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-ptc-dispatch-ui-foundation.md -2026-07-26-ptc-dispatch-ui-foundation.md: d91f914a564654a7e3925caf367478b282ddbdc4 -2026-07-26-ptc-dispatch-ui-foundation.zh.md: 8a26f0a1d4ccd4937b856b1c8f3a8be38d8c4174 +2026-07-26-ptc-dispatch-ui-foundation.md: 9abc3b31c93ed5013f3006d23d55cf4c5db67c57 +2026-07-26-ptc-dispatch-ui-foundation.zh.md: 66b01f2d5e7907386508fd9e633577d34051851f diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md index d91f914a56..9abc3b31c9 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md @@ -4,18 +4,18 @@ Status: implemented English | [中文](2026-07-26-ptc-dispatch-ui-foundation.zh.md) -> Scope: the host-side contract changes that let a UI render a PTC mode turn with the same fidelity as native tool calls — the foundation the other PTC mode UI notes build on. The [PTC mode foundation](2026-06-15-ptc.md) owns the transport design; this note owns the model-visible `description` parameter, the full-content `tool/ptc-dispatch` payload, and the temporary `DSH_TOOLS_MODE` enablement switch for the `dsh` config tree. +> Scope: the host-side contract changes that let a UI render a PTC mode turn with the same fidelity as native tool calls — the foundation the other PTC mode UI notes build on. The [PTC mode foundation](2026-06-15-ptc.md) owns the transport design; this note owns the model-visible `description` parameter, the full-content `tool/code-dispatch` payload, and the temporary `DSH_TOOLS_MODE` enablement switch for the `dsh` config tree. ## Problem -A `run_code` turn was opaque in every product surface. The call card's title was the raw program text — unreadable at row width, and unlike `bash` (whose required `description` labels the card while the command rides the expanded input) there was no model-authored label at all. The `tool/ptc-dispatch` event carried only a 200-char, cwd-normalized `resultSummary` of each sub-call, so no UI could ever show what a sub-call actually returned: the web conversation view ([chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md)) renders sub-calls through the exact components that render native `tool/result` cards, and a bounded summary cannot feed a native-parity card. And the `dsh web` composition had no way to enable PTC mode at all — the `tools` row pinned the schema default and the runtime was absent from the tree. +A `run_code` turn was opaque in every product surface. The call card's title was the raw program text — unreadable at row width, and unlike `bash` (whose required `description` labels the card while the command rides the expanded input) there was no model-authored label at all. The `tool/code-dispatch` event carried only a 200-char, cwd-normalized `resultSummary` of each sub-call, so no UI could ever show what a sub-call actually returned: the web conversation view ([chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md)) renders sub-calls through the exact components that render native `tool/result` cards, and a bounded summary cannot feed a native-parity card. And the `dsh web` composition had no way to enable PTC mode at all — the `tools` row pinned the schema default and the runtime was absent from the tree. ## Decision 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/ptc-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. +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 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-presentation 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-ptc-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md index 8a26f0a1d4..66b01f2d5e 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md @@ -4,18 +4,18 @@ Status: implemented [English](2026-07-26-ptc-dispatch-ui-foundation.md) | 中文 -> 范围:让 UI 能以与原生工具调用相同的保真度渲染 PTC mode 轮次的宿主侧约定变更,即其他 PTC mode UI Agent Note 赖以构建的基础。传输设计归 [PTC mode 基础](2026-06-15-ptc.zh.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/ptc-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用开关,归本篇所有。 +> 范围:让 UI 能以与原生工具调用相同的保真度渲染 PTC mode 轮次的宿主侧约定变更,即其他 PTC mode UI Agent Note 赖以构建的基础。传输设计归 [PTC mode 基础](2026-06-15-ptc.zh.md)所有;模型可见的 `description` 参数、携带完整内容的 `tool/code-dispatch` 载荷,以及 `dsh` 配置树上临时的 `DSH_TOOLS_MODE` 启用开关,归本篇所有。 ## 问题 -`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/ptc-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:Web 对话视图([chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md))会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 PTC mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 +`run_code` 轮次过去在每个产品界面上都不透明。调用卡片的标题就是原始程序文本,在行宽内无法阅读;而且不同于 `bash`(其必填的 `description` 用作卡片标签,命令本身放在展开后的输入里),`run_code` 完全没有模型撰写的标签。`tool/code-dispatch` 事件过去只携带每个子调用的 `resultSummary`(上限 200 字符、经 cwd 归一化),因此任何 UI 都无从展示子调用实际返回的内容:Web 对话视图([chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md))会用渲染原生 `tool/result` 卡片的同一批组件来渲染子调用,而有界摘要无法支撑一张与原生同等保真的卡片。同时,`dsh web` 组合此前根本无法启用 PTC mode:`tools` 行钉死在 schema 默认值上,配置树里也完全没有该运行时。 ## 决策 三项变更,每项对应一个障碍: 1. **`run_code` 新增必填的 `description` 参数**(与 bash 完全相同的约定:主动语态、5-10 个词、展示在 UI 中;仅含空白的取值在执行时被拒绝)。`presentCall` 现在以该 description 作为卡片标题,并把程序文本移入 `rawInput`。提示词侧的成本是每次调用多出几个 token;换来的是每个界面——TUI 卡片、ACP(Agent Client Protocol)标题、Web 行——都无需解析 TypeScript 就能获得可供人阅读的标签。 -2. **`tool/ptc-dispatch` 记录子调用面向模型的完整结果**(`content: ContentBlock[]` 加 `isError`,即 `tool/result` 的词汇),取代 `resultSummary`,并把摘要与 cwd 归一化机制彻底删除。UI 渲染子调用走的代码路径与渲染原生结果完全相同,包括错误文本和非文本块。该事件仍仅用于日志(`deriveMessages()` 忽略它):模型上下文没有任何变化。 +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 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml index 0ed68d66a7..91ddd782f3 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.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-ptc-live-parallel-dispatch.md -2026-07-26-ptc-live-parallel-dispatch.md: 647142d8bc23de211f2a788468c13c45f2139104 -2026-07-26-ptc-live-parallel-dispatch.zh.md: 7e66cdfa9aec020cd463df984da3ab20f83214e9 +2026-07-26-ptc-live-parallel-dispatch.md: 0a864411d52e1e6f6a68b11ceb1055f9bc6c5a89 +2026-07-26-ptc-live-parallel-dispatch.zh.md: 1e9f3cdd9159412c93996d77e759e0af34022408 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md index 647142d8bc..0a864411d5 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-26-ptc-live-parallel-dispatch.zh.md) -> Scope: the `tool/ptc-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). +> Scope: the `tool/code-dispatch-start` event, per-sub-call running state in the web chat, and the bridge's scheduler reusing the native concurrency contract. Builds on the [host foundation](2026-07-26-ptc-dispatch-ui-foundation.md) and [chat sub-call rows](2026-07-26-ptc-chat-subcall-rows.md); the native contract itself is owned by the [parallel tool-call note](2026-07-10-parallel-tool-call-execution.md). ## Problem @@ -14,7 +14,7 @@ Two gaps remained after the host foundation and chat sub-call rows shipped. Sub- **One lifecycle pair, one scheduling contract, shared with native.** -- **Event pair**: `tool/ptc-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/ptc-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0. +- **Event pair**: `tool/code-dispatch-start` (parent/sub ids, name, normalized args) is appended when the scheduler actually starts a call — not at submission, so a queued call abandoned by run settlement logs nothing. The existing `tool/code-dispatch` settles the pair (same `subCallId`); every started call settles exactly once (aborts settle as `isError` outcomes through the pipeline). Timing = the two events' `time` fields. Both stay log-only; model context is untouched; format stays v0. - **Bridge scheduler**: submitted calls are classified at start time via `registry.executionMode` (the SAME fail-closed `isConcurrencySafe` contract the loop uses) and start strictly in submission order. One single-lane driver owns every ORDERED stage — the start append, `prepare` (pre-execute/guards), the head-of-line `finalize`/`finish` commit (post-execute + context deferral + settle append) — so ordered policy stages never overlap each other and only the around-dispatch/body stage runs concurrently, exactly the native loop's sequencing (`fillPool` awaits `startCall` then `commitReady`). Consecutive parallel-classified calls overlap up to `maxParallelSubCalls` (a `Config` field validated by the Loader schema AND re-validated at direct construction, default 10 — the loop scheduler's own default; `1` restores serial dispatch); an exclusive call drains the pool, runs alone, and holds its barrier until its COMMIT completes (post-execute included), like a native exclusive group. Run settlement aborts in-flight dispatches and abandons queued-unstarted ones (binding rejection, no events), then drains to quiescence — including a commit already mid-flight when the program returned — before the outer result closes the turn. - **Client**: Runtime's `ToolCallTree` stores a start event as a `RunningToolCall` child and projects it through the parent's recursive `subCalls` (rows derive the running ring from that shape, exactly as for native in-flight calls). Its settle replaces the private-index entry in place, preserving start order under parallel completion and carrying the start's `time` as `callTime` (duration source). A settle with no observed start (window cut mid-pair, or a pre-start-event log) appends directly, so old logs keep rendering. - **SDK prompt**: the model-facing "calls execute sequentially" sentence is replaced with the true contract (independent safe calls may overlap under `Promise.all`; dependent work sequences with `await`) — a model-visible change, re-recorded across every ptc snapshot. diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md index 7e66cdfa9a..1e9f3cdd91 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-live-parallel-dispatch.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-26-ptc-live-parallel-dispatch.md) | 中文 -> 范围:`tool/ptc-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)与 [chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 +> 范围:`tool/code-dispatch-start` 事件、Web chat 中每个子调用的运行状态,以及桥接层调度器对原生并发约定的复用。构建在[宿主侧基础](2026-07-26-ptc-dispatch-ui-foundation.zh.md)与 [chat 子调用行](2026-07-26-ptc-chat-subcall-rows.zh.md)之上;原生约定本身归[并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.zh.md) 所有。 ## 问题 @@ -14,7 +14,7 @@ Status: implemented **一对生命周期事件,一份调度约定,与原生共用。** -- **事件对**:`tool/ptc-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/ptc-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响;格式保持 v0。 +- **事件对**:`tool/code-dispatch-start`(父/子 id、名称、规范化参数)在调度器真正启动某个调用时才追加,而非在提交时,因此因 run 结算而被放弃的排队调用不会留下任何日志。既有的 `tool/code-dispatch` 结算该事件对(`subCallId` 相同);每个已启动的调用恰好结算一次(中止也会作为 `isError` 结果经由流水线结算)。计时即这两个事件的 `time` 字段。两个事件仍仅用于日志;模型上下文不受影响;格式保持 v0。 - **桥接层调度器**:已提交的调用在启动那一刻经 `registry.executionMode` 分类(与 loop 所用完全相同、故障时默认判为不安全的 `isConcurrencySafe` 约定),并严格按提交顺序启动。所有有序阶段——start 事件追加、`prepare`(pre-execute/守卫)、队首 `finalize`/`finish` 提交(post-execute + 上下文延迟提交 + settle 事件追加)——由单通道驱动器独占执行,因此有序策略阶段彼此绝不重叠,只有 around-dispatch/工具体阶段并发运行,与原生 loop 的时序完全一致(`fillPool` 先 await `startCall` 再 `commitReady`)。连续被分类为可并行的调用可以重叠执行,上限为 `maxParallelSubCalls`(`Config` 字段,Loader schema 校验之外直接构造时也重新校验,默认值 10,即 loop 调度器自身的默认值;设为 `1` 即恢复串行分发);独占调用则先排空池、独自运行,且其屏障保持到自身提交(含 post-execute)完成为止,与原生独占分组一致。run 结算时会中止仍在运行的分发,并放弃已排队未启动的分发(绑定调用被拒绝,不产生事件),随后排空到完全停稳——包括程序返回时已在途的提交——之后外层结果才结束该轮次。 - **客户端侧**:运行时的 `ToolCallTree` 把 start 事件存为 `RunningToolCall` 子级,并通过父级递归的 `subCalls` 投影出来(行组件从该形状推导出运行指示环,与原生运行中的调用处理完全一致)。其结算事件会原位替换私有索引中的条目,即使并行完成也保持启动顺序不变,并把 start 事件的 `time` 作为 `callTime`(时长来源)带入。未观察到对应 start 的结算事件(窗口切在事件对中间,或日志录制于 start 事件引入之前)会直接追加,因此旧日志仍能照常渲染。 - **SDK 提示词**:面向模型的「调用按顺序执行」一句替换为真实约定(相互独立的安全调用可以在 `Promise.all` 下重叠执行;相互依赖的工作以 `await` 顺序衔接);这是模型可见的变更,每一份 PTC mode 快照都已重新录制。 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 b319069d32..6b3207b92f 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: 656e91a7a5f5e0e312726e1c02caa36cc06aa5e0 -2026-07-28-web-terminal-card.zh.md: cfcca327c1240c5d1b723fefc39e3cf962a8279e +2026-07-28-web-terminal-card.md: 2fd2b429c0424d1ae7421fe4c679c46902db62fd +2026-07-28-web-terminal-card.zh.md: 1405c03766316379ceb2c97ede153704268c35e6 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 656e91a7a5..2fd2b429c0 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 @@ -49,7 +49,7 @@ One premise of that split has since weakened: [tool rows stopped being details-p `TerminalBlock` reads only the terminal view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the terminal capability still gets the bridge's fenced fallback; nothing about the tool's result shape changed. -A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/ptc-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the ptc-dispatch wire is that boundary's own change. +A `run_code` sub-dispatch does not reach a terminal card on the shipped wire: `session.ts` folds `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and the host's `viewFor` presents only top-level `tool/call`/`tool/result`, so a nested bash call keeps the generic flattened form. Both arms are pinned — the resolution path with views injected, and the no-view shape the wire actually delivers — so the gap is recorded rather than implied. Carrying presenter views through the ptc-dispatch wire is that boundary's own change. Inline rendering is licensed for the terminal intent alone. A future intent that wants it needs its own bound and its own decision, argued against the reason recorded here rather than against the panel-only convention on its own. 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 cfcca327c1..1405c03766 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 @@ -51,7 +51,7 @@ Web client 却对它视而不见。`packages/client/ui-tool/src/client/tool/mode `TerminalBlock` 只读取 terminal 视图携带的字段,因此它始终是渲染意图内容的纯函数——不查会话状态,与产出该视图的 presenter 一样可安全回放。不具备终端能力的 UI 仍从桥接层拿到围栏式回退;工具的结果形态未作任何改动。 -在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/ptc-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 ptc-dispatch wire 属于该边界自身的改动。 +在当前已交付的 wire 上,`run_code` 子派发不会得到终端卡片:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `callView: null`/`resultView: null`,而 host 的 `viewFor` 只呈现顶层的 `tool/call`/`tool/result`,因此嵌套的 bash 调用保持通用的压平形式。两条分支都已钉住——注入视图后的解析路径,以及 wire 实际投递的无视图形态——因此这个缺口是被记录下来的,而非暗含的。把 presenter 视图贯穿 ptc-dispatch wire 属于该边界自身的改动。 内嵌渲染的许可仅授予 terminal 意图。将来想要内嵌的意图需要有自己的边界与自己的决定,且需针对此处记录的理由来论证,而不是仅针对「只在面板」这条约定本身。 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 0664c01644..7adf65e02a 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: ee03e966ce4475625dace7fb4f4bcee9dc4fa9c8 -2026-07-30-web-read-card-frontend.zh.md: 6c26e4f2c3747b142a62c2f490994cac6c56dd99 +2026-07-30-web-read-card-frontend.md: 98e31d4192f7522f9d0e23bce56372a70f8c50b6 +2026-07-30-web-read-card-frontend.zh.md: 50e1ca8d05a38d13de15fdd6cdd713fd1758ffd4 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 ee03e966ce..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 @@ -36,7 +36,7 @@ Whole-row collapse/expand (defaulting every tool call to collapsed) is owned by `ui-primitives` gains `ReadBlock` and `highlightLines`; no new runtime dependency (shiki was already present for `CodeBlock`). `ReadBlock` reads only the read view's fields, so it stays a pure function of what the render intent carries — no session lookups, replay-safe like the presenters that produce the view. A UI without the read capability still gets the backend's `content` fallback (the envelope-stripped text) through the generic card, unchanged. -A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/ptc-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form. +A read row in the Web chat now carries the file content resident, a deliberate density increase over a summary-only row, bounded by the chat cap. A `run_code` sub-dispatch does not reach a read card on the shipped wire for the same reason a nested bash call does not reach a terminal card: `session.ts` folds `tool/code-dispatch(-start)` with `resultView: null`, so a nested read keeps the generic flattened form. ## Testing 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 6c26e4f2c3..50e1ca8d05 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 @@ -36,7 +36,7 @@ Status: implemented `ui-primitives` 增加 `ReadBlock` 和 `highlightLines`;没有新的运行时依赖(shiki 已因 `CodeBlock` 存在)。`ReadBlock` 只读取读取视图的字段,因此保持为渲染意图所承载内容的纯函数 —— 无会话查询,与产出该视图的 presenter 一样可安全回放。没有读取能力的 UI 仍通过通用卡片拿到后端的 `content` 回退(剥掉外壳的文本),保持不变。 -Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。按已发布的协议格式,`run_code` 子派发不会到达读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts` 把 `tool/ptc-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。 +Web 聊天里的读取行现在常驻承载文件内容,是相对纯摘要行的一次刻意的密度增加,受聊天上限约束。按已发布的协议格式,`run_code` 子派发不会到达读取卡片,与嵌套 bash 调用到不了终端卡片同因:`session.ts` 把 `tool/code-dispatch(-start)` 折叠为 `resultView: null`,因此嵌套读取保持通用的摊平形式。 ## Testing diff --git a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts index ea761cce71..94c83aef42 100644 --- a/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/ptc.e2e.ts @@ -49,7 +49,7 @@ afterEach(async () => { workdir = undefined }) -async function codeModeHarness(cwd: string): Promise { +async function ptcModeHarness(cwd: string): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) @@ -66,7 +66,7 @@ async function codeModeHarness(cwd: string): Promise { return harness } -async function workspaceCodeModeHarness(): Promise { +async function workspacePtcModeHarness(): Promise { const harness = new Context() await harness.plugin(LlmRuntime) await harness.plugin(SessionStore) @@ -112,7 +112,7 @@ function completion(result: ToolExecutionResult): unknown { } /** Keyless real-worker harness for direct typed-binding acceptance tests. */ -async function typedCodeModeHarness(): Promise { +async function typedPtcModeHarness(): Promise { const harness = new Context() await harness.plugin(SystemPrompt) await harness.plugin(ToolRuntime, { mode: 'ptc' }) @@ -121,8 +121,8 @@ async function typedCodeModeHarness(): Promise { } /** Keyless real-worker harness with the task-owned bash lifecycle. */ -async function backgroundCodeModeHarness(cwd: string): Promise { - const harness = await typedCodeModeHarness() +async function backgroundPtcModeHarness(cwd: string): Promise { + const harness = await typedPtcModeHarness() await harness.plugin(LocalJobRegistry) await harness.plugin(ToolTasks, {}) await harness.plugin(LocalSubprocessRuntime) @@ -134,7 +134,7 @@ async function backgroundCodeModeHarness(cwd: string): Promise { describe('PTC mode typed values: keyless real-worker contracts', () => { it('crosses a large intermediate value intact and exposes only typed tool failure fields', async () => { - ctx = await typedCodeModeHarness() + ctx = await typedPtcModeHarness() ctx.tools.register(defineTool({ name: 'large_value', description: 'Return a large canonical string.', @@ -188,7 +188,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { it('returns a background job id, settles the outer run, and polls that id to completion', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-background-')) - ctx = await backgroundCodeModeHarness(workdir) + ctx = await backgroundPtcModeHarness(workdir) const jobId = completion(await runCode(ctx, ` const started = await tools.bash({ @@ -211,7 +211,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { it('pre-abort spawns nothing; post-publication abort leaves job_kill as the cancellation owner', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-task-cancel-')) - ctx = await backgroundCodeModeHarness(workdir) + ctx = await backgroundPtcModeHarness(workdir) const pre = new AbortController() pre.abort('pre-aborted') @@ -248,7 +248,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { it('keeps foreground bash coupled to the outer signal', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-foreground-cancel-')) - ctx = await backgroundCodeModeHarness(workdir) + ctx = await backgroundPtcModeHarness(workdir) const controller = new AbortController() const startedAt = Date.now() const pending = runCode(ctx, ` @@ -262,7 +262,7 @@ describe('PTC mode typed values: keyless real-worker contracts', () => { }, 15_000) it('uses versioned Cordis DTO ids directly for running and pending Plugins, then confirms removal', async () => { - ctx = await typedCodeModeHarness() + ctx = await typedPtcModeHarness() await ctx.plugin(CordisHostRunner) await ctx.plugin(ToolCordis) const agent = { @@ -352,7 +352,7 @@ function waitForIdle(harness: Context, agent: Agent): Promise { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a program over real tools', () => { it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-ptc-e2e-')) - ctx = await codeModeHarness(workdir) + ctx = await ptcModeHarness(workdir) const agent = ctx.agentLoop.create(SessionId('e2e-ptc'), { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) agent.followup(createUserMessage({ @@ -401,7 +401,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('PTC mode: real model writes a pr await mkdir(join(workdir, 'pkg/deep'), { recursive: true }) await writeFile(join(workdir, 'pkg/AGENTS.md'), `If asked for the PTC mode workspace handshake, reply with exactly ${WORKSPACE_PROBE} and nothing else.\n`) await writeFile(join(workdir, 'pkg/deep/task.txt'), 'Touch this file to discover the nested instructions.\n') - ctx = await workspaceCodeModeHarness() + ctx = await workspacePtcModeHarness() const handle = await ctx.agents.create({ sessionId: SessionId('e2e-ptc-workspace-session'), meta: { cwd: workdir }, diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9c48fe3636..23fd30b228 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: cb2dfbdee786b2b6203e4e6f4f08fe8ad1d84a2f -config-catalog.zh.md: 60ccf2bf7a001caa2e0fd305476c0ef73fc8db2c +config-catalog.md: 7e4e4b626161fb8468da48d07dabe51b626c94d0 +config-catalog.zh.md: 2e48ea06cb4179db78b946e55f289dd1d7bc71e1 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cb2dfbdee7..7e4e4b6261 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -261,7 +261,7 @@ Requires: `tools` export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, - * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * `ptc` sends only `run_code` plus a generated SDK, `both` sends both. * Required rather than defaulted: the deployment default is what a preset * without this row already gets, so an omitted value would mean the row was * composed for nothing. diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 60ccf2bf7a..2e48ea06cb 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -263,7 +263,7 @@ export interface GoalConfig { export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, - * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * `ptc` sends only `run_code` plus a generated SDK, `both` sends both. * Required rather than defaulted: the deployment default is what a preset * without this row already gets, so an omitted value would mean the row was * composed for nothing. diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7613f6fa12..39462f81db 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md persistence-catalog.md: 2143f7881edad36a986869fcbb458ae464014a44 -persistence-catalog.zh.md: e37d6ed5fd1c7bd3730c411312fef186c89a4b20 +persistence-catalog.zh.md: abe6511a8ad9edbbc0e73ab77e02cf10d6346364 diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index e37d6ed5fd..abe6511a8a 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -852,9 +852,9 @@ export type SessionEvent = { 来源:[`packages/core/session/src/types.ts:266`](../packages/core/session/src/types.ts) - + -#### `tool/ptc-dispatch` — log-only +#### `tool/code-dispatch` — log-only ```ts persistence-catalog /** @@ -877,9 +877,9 @@ export type SessionEvent = { 来源:[`packages/core/tools/src/types.ts:56`](../packages/core/tools/src/types.ts) - + -#### `tool/ptc-dispatch-start` — log-only +#### `tool/code-dispatch-start` — log-only ```ts persistence-catalog /** diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index acb9055376..17f6aae194 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md tool-catalog.md: 91ff093e79cc05a2c08b5aa1130378440cd963f3 -tool-catalog.zh.md: 48627ed04fd264f9ea28842a94596667b9629ec5 +tool-catalog.zh.md: 5aa915489c415b5d58fa1d5181431cf42ef3c53f diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 48627ed04f..5aa915489c 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -20,7 +20,7 @@ | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `code` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `code` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | diff --git a/docs/tool-execution-pipeline.i18n.yaml b/docs/tool-execution-pipeline.i18n.yaml index 62ee38c9dd..6ff5ef0a15 100644 --- a/docs/tool-execution-pipeline.i18n.yaml +++ b/docs/tool-execution-pipeline.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-execution-pipeline.md tool-execution-pipeline.md: a799c68a60f6782ef3bb79c52f89cbc78d762ab3 -tool-execution-pipeline.zh.md: 04a242f47abe55ce43ac536cc5261a3b075d8b4f +tool-execution-pipeline.zh.md: 7ffdd34298f630c975b23f23daf875a3c4cdc84d diff --git a/docs/tool-execution-pipeline.zh.md b/docs/tool-execution-pipeline.zh.md index 04a242f47a..7ffdd34298 100644 --- a/docs/tool-execution-pipeline.zh.md +++ b/docs/tool-execution-pipeline.zh.md @@ -59,6 +59,6 @@ flowchart TD allResults --> context ``` -文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。PTC mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/ptc-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 +文件系统的先读后编辑检查位于 `tool-fs` 之下,通过 `fs/*` 事件实现。通用的前置/后置 waterfall 承载钩子与审批策略;`ctx.approval` 在单调守卫之前处理询问,而不得重新排序的所有者策略仍作为已注册的守卫。超时等环绕分发关注点对 `tools/execute` 进行包装。注册表会对候选结果进行无损快照;如果快照失败,则会先将失败规范化,之后再由可见定义中已随快照固定的 `finalizeContent` 回调强制执行其同步且仅限内容的不变式。随后,`tools/result` 会观察不可变、可由 JSON 无损表示的结果。这样一来,钩子便可跨越不同工具系列,而无需让工具与某个策略服务耦合。PTC mode 会将保留的 `run_code` 传输及其序列化子调用都送入流水线;子调用携带父级 token、记录 `tool/code-dispatch`、将拒绝呈现为具有约束力的驳回,并省略 `additionalContexts`,以保持调用与结果相邻。 维护模式:英文源文件包含人工维护的 Mermaid 流程图,并由生成器写出;本中文文件作为经评审对侧通过双语配对维护。确切的工具 schema 与事件签名位于生成的目录中。 diff --git a/packages/core/agent-tool-presentation/src/index.ts b/packages/core/agent-tool-presentation/src/index.ts index 5db95894fc..fb4880a428 100644 --- a/packages/core/agent-tool-presentation/src/index.ts +++ b/packages/core/agent-tool-presentation/src/index.ts @@ -38,7 +38,7 @@ export const inject = ['tools'] export interface Config { /** * The form this agent's model sees. `native` sends every visible schema, - * `code` sends only `run_code` plus a generated SDK, `both` sends both. + * `ptc` sends only `run_code` plus a generated SDK, `both` sends both. * Required rather than defaulted: the deployment default is what a preset * without this row already gets, so an omitted value would mean the row was * composed for nothing. diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index 058972c9eb..f5ebe8862f 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -11,7 +11,7 @@ * The policy only decides WHEN to spill and composes the notice. * * A second arm applies the SAME cap to the durable log: the - * `tools/ptc-dispatch-log` waterfall bounds the `tool/ptc-dispatch` event's + * `tools/ptc-dispatch-log` waterfall bounds the `tool/code-dispatch` event's * copy of an oversized `run_code` sub-call result (the program's value is * untouched; UIs and replay read the full text through the spill artifact). * @@ -208,7 +208,7 @@ export function apply(ctx: Context, config: Config): void { return { kind: 'accept', content: replaced, ...decision.additionalContexts ? { additionalContexts: decision.additionalContexts } : {} } }, { prepend: true }) - // The durable-log arm: bound the `tool/ptc-dispatch` event's copy of an + // The durable-log arm: bound the `tool/code-dispatch` event's copy of an // oversized sub-call result the same way the model-facing arm bounds an // outer result. The program's returned value is untouched (it already // crossed the worker boundary whole); only the session log's copy shrinks From 409f9ee30424fd9b53fa6172d45e2d586e8d3f78 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:22:51 +0800 Subject: [PATCH 120/130] fix: repair merged README remnants and note links after the master rebase Apply the rename pass to READMEs and docs the master sweep rewrote, fix PTC mode anchors and the renamed-note links in the spill READMEs, and regenerate the doc graphs. --- docs/capability-seams.i18n.yaml | 4 ++-- docs/capability-seams.md | 4 ++-- docs/capability-seams.zh.md | 4 ++-- packages/README.i18n.yaml | 4 ++-- packages/README.md | 2 +- packages/README.zh.md | 2 +- packages/bundle/headless/README.i18n.yaml | 4 ++-- packages/bundle/headless/README.md | 2 +- packages/bundle/headless/README.zh.md | 2 +- packages/bundle/web-app/README.i18n.yaml | 4 ++-- packages/bundle/web-app/README.md | 2 +- packages/bundle/web-app/README.zh.md | 2 +- packages/client/ui-tool/README.i18n.yaml | 4 ++-- packages/client/ui-tool/README.md | 2 +- packages/client/ui-tool/README.zh.md | 2 +- .../client/ui-workflow-run/README.i18n.yaml | 4 ++-- packages/client/ui-workflow-run/README.md | 2 +- packages/client/ui-workflow-run/README.zh.md | 2 +- packages/code-runtime/README.i18n.yaml | 4 ++-- packages/code-runtime/README.md | 4 ++-- packages/code-runtime/README.zh.md | 4 ++-- .../code-runtime-python/README.i18n.yaml | 4 ++-- .../code-runtime-python/README.md | 2 +- .../code-runtime-python/README.zh.md | 2 +- .../README.i18n.yaml | 4 ++-- .../code-runtime-worker-thread/README.md | 12 +++++----- .../code-runtime-worker-thread/README.zh.md | 12 +++++----- .../code-runtime/README.i18n.yaml | 4 ++-- packages/code-runtime/code-runtime/README.md | 14 +++++------ .../code-runtime/code-runtime/README.zh.md | 14 +++++------ .../agent-instructions/README.i18n.yaml | 4 ++-- packages/context/agent-instructions/README.md | 2 +- .../context/agent-instructions/README.zh.md | 2 +- .../agent-tool-presentation/README.i18n.yaml | 4 ++-- .../core/agent-tool-presentation/README.md | 18 +++++++------- .../core/agent-tool-presentation/README.zh.md | 16 ++++++------- packages/core/tools/README.md | 24 +++++++++---------- packages/core/tools/README.zh.md | 24 +++++++++---------- .../agent-spine-demo/README.i18n.yaml | 4 ++-- packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/README.zh.md | 2 +- packages/mcp/mcp-client/README.i18n.yaml | 4 ++-- packages/mcp/mcp-client/README.md | 4 ++-- packages/mcp/mcp-client/README.zh.md | 4 ++-- packages/spill/README.i18n.yaml | 4 ++-- packages/spill/README.md | 2 +- packages/spill/README.zh.md | 2 +- packages/spill/spill-policy/README.i18n.yaml | 4 ++-- packages/spill/spill-policy/README.md | 2 +- packages/spill/spill-policy/README.zh.md | 2 +- .../subagent-fork-in-process/README.i18n.yaml | 4 ++-- .../subagent-fork-in-process/README.md | 2 +- .../subagent-fork-in-process/README.zh.md | 2 +- .../README.i18n.yaml | 4 ++-- .../subagent-in-process-driver/README.md | 4 ++-- .../subagent-in-process-driver/README.zh.md | 4 ++-- .../README.i18n.yaml | 4 ++-- .../subagent-spawn-in-process/README.md | 2 +- .../subagent-spawn-in-process/README.zh.md | 2 +- .../workflow/tool-workflow/README.i18n.yaml | 4 ++-- packages/workflow/tool-workflow/README.md | 2 +- packages/workflow/tool-workflow/README.zh.md | 2 +- 62 files changed, 149 insertions(+), 149 deletions(-) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 97d450238e..e004cf0452 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: 2ff2c3641f163c46ba19616bf35d60df82b3c748 -capability-seams.zh.md: 3cb636d0a997f4c36905f6434d7f2b2d0a22dbd8 +capability-seams.md: 5c3ba40df35f40e211de2bf90037f998eeb5a15d +capability-seams.zh.md: 47402e1b6d9514a77e8fff6c4f0d0a8bbb34586d diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2ff2c3641f..5c3ba40df3 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -494,7 +494,7 @@ flowchart LR | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns Code Mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation. | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI front ends provide the active human-answer provider; tool-ask-user pauses a tool call on the provider-neutral ask() promise. | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | Folds logged plan/mode state, flushes user selections at turn boundaries, renders deployment-owned guidance, registers /plan, and keeps the plan-exit schema stable across transitions. | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | Discovers preset directories over trusted and user-authored roots and mounts one preset cordis.yml under an agent scope during creation, rejecting a row that never activates or that publishes into the root service realm. | @@ -515,7 +515,7 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | The one home for the deployment default mode + workspace root; only the sandboxed executor and provider read the service (the tool layers use the pure `sandbox/mode` fold it also exports). Both enforcing families read it so bash and fs cannot confine to different roots. | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | One-shot permission decisions dispatched over the `approval/request` waterfall; answerers are listeners (the ACP bridge for its own agents), absence fails closed to `unavailable`. | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | User-facing preset table (`workspace-write`/`danger-full-access`) bundling the sandbox-mode and approval-policy knobs; a switch writes one `permission/preset` event through to both knob events. | -| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | +| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for PTC mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs executes read/write/edit through ctx.fs; fs-sandbox fences mutations by the shared sandbox mode; fs-observation-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 3cb636d0a9..47402e1b6d 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -496,7 +496,7 @@ flowchart LR | `ctx.sessionReferenceResolver` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session/session-title) | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm), [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | - | - | 负责确定性回退、最新标题折叠区,以及唯一的可选异步提供方注册。 | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-web`](../packages/web/tool-web) | - | 为每个步骤收集提示词各部分和面向模型的工具 schema。 | -| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 Code Mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | +| `ctx.tools` | `core` | [`tools`](../packages/core/tools) | - | [`agent-loop`](../packages/core/agent-loop), [`tool-ask-user`](../packages/interaction/tool-ask-user), [`tool-bash`](../packages/shell/tool-bash), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-fs`](../packages/fs/tool-fs), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-todo`](../packages/todo/tool-todo), [`tool-web`](../packages/web/tool-web) | - | 注册能力,负责 PTC mode 传输,并让调用依次经过策略前处理、单调守卫、环绕分派、策略后处理和最终结果观测。 | | `ctx.userQuestions` | `seam` | [`user-questions`](../packages/interaction/user-questions) | - | [`tool-ask-user`](../packages/interaction/tool-ask-user) | - | UI 前端提供当前生效的人工回答提供方;tool-ask-user 在提供方无关的 ask() promise 上暂停工具调用。 | | `ctx.planMode` | `core` | [`plan-mode`](../packages/plan/plan-mode) | - | - | - | 折叠已记录的计划/模式状态,在轮次边界刷新用户选择,渲染由部署方拥有的指导信息,注册 /plan,并在状态转换期间保持计划退出 schema 稳定。 | | `ctx.agentPresets` | `core` | [`agent-presets`](../packages/preset/agent-presets) | - | - | - | 在受信任根目录与用户创作根目录上发现 preset 目录,并在创建期把一份 preset cordis.yml 挂载到 agent 作用域之下,拒绝始终未激活或向根服务 realm 发布服务的行。 | @@ -517,7 +517,7 @@ flowchart LR | `ctx.sandboxPolicy` | `core` | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | - | [`bash-sandbox`](../packages/shell/bash-sandbox), [`fs-sandbox`](../packages/fs/fs-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash) | - | 统一保存部署默认模式和工作区根目录;只有沙箱执行器和提供方读取该服务(工具层使用它同时导出的纯 `sandbox/mode` 折叠区)。两类强制执行组件都读取该服务,因此 bash 与 fs 不会限制到不同的根目录。 | | `ctx.approval` | `seam` | [`user-approval`](../packages/interaction/user-approval) | - | [`tools`](../packages/core/tools), [`tool-bash`](../packages/shell/tool-bash), [`acp`](../packages/acp/acp) | - | 一次性权限决策通过 `approval/request` waterfall(瀑布式事件)分派;回答方是监听器(即 ACP 为自身 agent 提供的桥接),没有回答方时以 `unavailable` 关闭失败。 | | `ctx.permissionPresets` | `core` | [`permission-presets`](../packages/interaction/permission-presets) | - | - | - | 面向用户的预设表(`workspace-write`/`danger-full-access`),将沙箱模式与审批策略选项组合在一起;一次切换会写入一个 `permission/preset` 事件,并贯通到两个选项事件。 | -| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | 使用 Host 提供的异步绑定运行一段由模型编写的程序;各后端采用不同的基础环境和语言(工具注册表在 Code Mode 下消费该服务)。 | +| `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | [`tools`](../packages/core/tools) | - | 使用 Host 提供的异步绑定运行一段由模型编写的程序;各后端采用不同的基础环境和语言(工具注册表在 PTC mode 下消费该服务)。 | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`fs-e2b`](../packages/e2b/fs-e2b) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | tool-fs 通过 ctx.fs 执行读取/写入/编辑;fs-sandbox 按共享沙箱模式限制变更;fs-observation-policy 通过 fs/* 事件门禁贡献基于观测状态的检查。 | | `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 | | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 7816391ff8..158a9ead5c 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: c3dcbd3032e3eadad21b640f3a614ef5a1a6c6e7 -README.zh.md: 80518ef0de7c2022db45aefa411f507870cf09aa +README.md: e1a729a6c80d8f8125e0d4c4b038dc631edd7a26 +README.zh.md: 754ad0fc44677afb243c3a32a0281f58ec3b3af2 diff --git a/packages/README.md b/packages/README.md index c3dcbd3032..e1a729a6c8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -40,7 +40,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | | [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tools | | [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, model-facing tools | -| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | +| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + PTC mode Consumer | | [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | | [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, discovery tools | | [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index 80518ef0de..754ad0fc44 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -40,7 +40,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | | [`shell/`](shell/README.zh.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | | [`terminal/`](terminal/README.zh.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现、面向模型的工具 | -| [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | +| [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + PTC mode Consumer | | [`sandbox/`](sandbox/README.zh.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | | [`fs/`](fs/README.zh.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、发现工具 | | [`lsp/`](lsp/README.zh.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index fc0be1d54b..0aba9ff549 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/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/bundle/headless/README.md -README.md: 952ae369e58b60389a951f2f48a080d735a3d9ad -README.zh.md: 8d9084550fd295c19b679b64cf17f0e2cdafbeb6 +README.md: 282fa76dd720751d61ba7f44e5e35884d5f21e8f +README.zh.md: 5a2727a25e6e4840900ebc9dc70d10ff13db12dd diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 952ae369e5..282fa76dd7 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -65,7 +65,7 @@ The runner awaits the complete application (`ctx.get('loader')?.await()`) so the ### Patch surface over base -The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona on the base `system-prompt` row, keeps the same temporary process-wide Code Mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts Code Mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. +The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona on the base `system-prompt` row, keeps the same temporary process-wide PTC mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts PTC mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. ### Exit mapping diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index 8d9084550f..5a2727a25e 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -65,7 +65,7 @@ runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组 ### 叠加在 base 之上的 patch 表层 -patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona,保留与 Web 表层相同的临时进程级 Code Mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR 行,把 Code Mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。 +patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona,保留与 Web 表层相同的临时进程级 PTC mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR 行,把 PTC mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。 ### 退出映射 diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 237686c71a..4f438e7d45 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: ed2c341c3eb5b3a5ad2b298c4babd0fc19a066e2 -README.zh.md: 1f57a0beff91fc341bef6f9c6e79dab4f1d1b564 +README.md: 5588c7e72e6d61454620075c239ea571d026c5cf +README.zh.md: bfd7a58bf5343ddd06b49b97b622493f389da72f diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index ed2c341c3e..5588c7e72e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -73,7 +73,7 @@ The bundle is one patch plus one runtime glue plugin. The storage stack and proj ### Patch semantics -A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona, the `DSH_TOOLS_MODE` Code Mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. +A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona, the `DSH_TOOLS_MODE` PTC mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. ### Readiness diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 1f57a0beff..bfd7a58bf5 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -73,7 +73,7 @@ dsh --profile web --no-open --port 8080 ### patch 语义 -patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona、`DSH_TOOLS_MODE` Code Mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 +patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona、`DSH_TOOLS_MODE` PTC mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 ### 就绪宣告 diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 11f131475f..5cfd56493f 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/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-tool/README.md -README.md: 792a70b7b3cd2d7a48187e872104bffac1d54202 -README.zh.md: 0feeba622cd4bcee1d5affff252e7596b7190338 +README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100 +README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 792a70b7b3..773a93801e 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -96,7 +96,7 @@ None; this package neither assembles nor sends a provider request. These limits define the dispatch depth and the view ownership; they are current package constraints. -- **The Host excludes `run_code` from Code Mode program bindings** — production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. +- **The Host excludes `run_code` from PTC mode program bindings** — production events produce one dispatch level; the recursive Runtime/UI contract supports nesting. - **First-party Tool views are colocated here** — they can move to their owning business packages independently through the keyed slot. - **Tool copy reuses the `ui-conversation` locale namespace** — tool titles, row chrome, and Cordis-free primitive labels use that dictionary; presenter models retain locale keys or data rather than rendered wording. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 0feeba622c..88df08d7b5 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -96,7 +96,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` 这些限制定义分派深度与视图归属;它们是当前包约束。 -- **Host 不把 `run_code` 暴露为 Code Mode 程序 binding**:生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 +- **Host 不把 `run_code` 暴露为 PTC mode 程序 binding**:生产事件只产生一层分发;递归的运行时/UI 约定支持嵌套。 - **第一方工具视图集中在本包**:它们可以通过 keyed slot 独立迁移到各自所属的业务包。 - **工具文案复用 `ui-conversation` locale namespace**:工具标题、行 chrome 与无 Cordis 的 primitive label 使用该字典;presenter model 保留 locale key 或数据,而不是已渲染文案。 diff --git a/packages/client/ui-workflow-run/README.i18n.yaml b/packages/client/ui-workflow-run/README.i18n.yaml index 389abffb90..3df2ccbc96 100644 --- a/packages/client/ui-workflow-run/README.i18n.yaml +++ b/packages/client/ui-workflow-run/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-workflow-run/README.md -README.md: 83918ae56de622625124ce219deb2022932df690 -README.zh.md: 8a86bff2b19b85b9d26b17c395fe546712febca2 +README.md: 1cf482bb8f9d0f8c41e82a639fa6cb656f5fd563 +README.zh.md: 2b559c666b1ea00374717b9f401ea02113459ac6 diff --git a/packages/client/ui-workflow-run/README.md b/packages/client/ui-workflow-run/README.md index 83918ae56d..1cf482bb8f 100644 --- a/packages/client/ui-workflow-run/README.md +++ b/packages/client/ui-workflow-run/README.md @@ -85,7 +85,7 @@ None; this package neither assembles nor sends a provider request. These limits define which runs produce records and what the node exposes; they are current package constraints. -- **Only top-level calls through `dsh-tool-workflow` produce these records** — nested Code Mode calls and direct `WorkflowEngine` consumers do not. +- **Only top-level calls through `dsh-tool-workflow` produce these records** — nested PTC mode calls and direct `WorkflowEngine` consumers do not. - **Navigation is intentionally live-only** — terminal members remain visible for review but never expose a cold-session opener from this node. - **The node shows run, phase, member identity, and status only** — scripts, outputs, errors, logs, usage, static topology, and controls remain outside this surface. diff --git a/packages/client/ui-workflow-run/README.zh.md b/packages/client/ui-workflow-run/README.zh.md index 8a86bff2b1..2b559c666b 100644 --- a/packages/client/ui-workflow-run/README.zh.md +++ b/packages/client/ui-workflow-run/README.zh.md @@ -85,7 +85,7 @@ kind: "package-reference" 这些限制定义哪些运行会产生记录、节点暴露什么;它们是当前包约束。 -- **只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录**:嵌套 Code Mode 调用和直接 `WorkflowEngine` 消费方不会生成。 +- **只有经 `dsh-tool-workflow` 发起的顶层调用会生成这些记录**:嵌套 PTC mode 调用和直接 `WorkflowEngine` 消费方不会生成。 - **导航刻意只面向实时运行**:终态成员继续保留供复盘,但本节点永不为其提供冷 Session 入口。 - **节点只显示运行、阶段、成员身份与状态**:脚本、输出、错误、日志、用量、静态拓扑与控制操作都不属于本界面。 diff --git a/packages/code-runtime/README.i18n.yaml b/packages/code-runtime/README.i18n.yaml index e741cad14c..b3d5e56e92 100644 --- a/packages/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/README.md -README.md: aea19fd7bcfa4cb018c387a0ef0fd8ad6ad499c2 -README.zh.md: 142d9bcc666d420536c774957973312c52ce33a3 +README.md: 165727f8b57d5392cca0fc8bc028f6b39efe35b2 +README.zh.md: c3c2cf04dac91d40d5a748ad58a359fa3b60e27c diff --git a/packages/code-runtime/README.md b/packages/code-runtime/README.md index aea19fd7bc..165727f8b5 100644 --- a/packages/code-runtime/README.md +++ b/packages/code-runtime/README.md @@ -35,10 +35,10 @@ These three packages together provide program execution; each README describes w ## Related documentation -Start with the subsystem reference for the service contract, then the Code Mode design that consumes this capability and the capability-seam model it follows. +Start with the subsystem reference for the service contract, then the PTC mode design that consumes this capability and the capability-seam model it follows. - [Code runtime subsystem reference](../../docs/subsystems/code-runtime.md) — request/result vocabulary, bindings, and the `ctx.codeRuntime` cordis surface. -- [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) — how the tool registry presents `run_code` to the model. +- [PTC mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-ptc.md) — how the tool registry presents `run_code` to the model. - [Capability seams](../../docs/capability-seams.md) — the Service Definition / Service Provider / Consumer split this family follows. diff --git a/packages/code-runtime/README.zh.md b/packages/code-runtime/README.zh.md index 142d9bcc66..c3c2cf04da 100644 --- a/packages/code-runtime/README.zh.md +++ b/packages/code-runtime/README.zh.md @@ -35,10 +35,10 @@ kind: "package-group" ## 相关文档 -先从子系统参考了解服务约定,再看消费此能力的 Code Mode 设计,以及它所遵循的能力 seam 模型。 +先从子系统参考了解服务约定,再看消费此能力的 PTC mode 设计,以及它所遵循的能力 seam 模型。 - [代码运行时子系统参考](../../docs/subsystems/code-runtime.zh.md)——请求/结果词汇、绑定与 `ctx.codeRuntime` 的 cordis 接口面。 -- [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md)——工具注册表如何把 `run_code` 呈现给模型。 +- [PTC mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md)——工具注册表如何把 `run_code` 呈现给模型。 - [能力 seam](../../docs/capability-seams.zh.md)——本家族遵循的 Service Definition / Service Provider / Consumer 拆分。 diff --git a/packages/code-runtime/code-runtime-python/README.i18n.yaml b/packages/code-runtime/code-runtime-python/README.i18n.yaml index d1b381869f..535aee0b2d 100644 --- a/packages/code-runtime/code-runtime-python/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-python/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/code-runtime/code-runtime-python/README.md -README.md: 22015809b88dd9d71d6cc8410b993a8b2ca0d93b -README.zh.md: 124ac64d326c5d0a779ceefe6f17cb18bcca8904 +README.md: 7aea5ee4c031a36718e66a583762f61832596d04 +README.zh.md: 778cc61ef28be616b8b0ed1ce8f0c9396143a768 diff --git a/packages/code-runtime/code-runtime-python/README.md b/packages/code-runtime/code-runtime-python/README.md index 22015809b8..7aea5ee4c0 100644 --- a/packages/code-runtime/code-runtime-python/README.md +++ b/packages/code-runtime/code-runtime-python/README.md @@ -94,7 +94,7 @@ Read these when the protocol contract is not enough. They move from the seam def ## Model Experience -Indirectly, through Code Mode in `dsh-tools`, which renders the program's completion value or failure into a retained `run_code` result. +Indirectly, through PTC mode in `dsh-tools`, which renders the program's completion value or failure into a retained `run_code` result. #### KV Cache effect diff --git a/packages/code-runtime/code-runtime-python/README.zh.md b/packages/code-runtime/code-runtime-python/README.zh.md index 124ac64d32..778cc61ef2 100644 --- a/packages/code-runtime/code-runtime-python/README.zh.md +++ b/packages/code-runtime/code-runtime-python/README.zh.md @@ -94,7 +94,7 @@ host 侧校验会静默丢弃垃圾,因此格式错误或伪造的帧绝不会 ## 模型体验 -通过 `dsh-tools` 中的 Code Mode 间接提供;后者把程序的完成值或失败渲染进一个保留的 `run_code` 结果。 +通过 `dsh-tools` 中的 PTC mode 间接提供;后者把程序的完成值或失败渲染进一个保留的 `run_code` 结果。 #### KV Cache 影响 diff --git a/packages/code-runtime/code-runtime-worker-thread/README.i18n.yaml b/packages/code-runtime/code-runtime-worker-thread/README.i18n.yaml index cbe6322b17..9608351798 100644 --- a/packages/code-runtime/code-runtime-worker-thread/README.i18n.yaml +++ b/packages/code-runtime/code-runtime-worker-thread/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/code-runtime/code-runtime-worker-thread/README.md -README.md: 40f2c735f13efdb41a047c4f582ecf98220825d8 -README.zh.md: b9389d359dd8327e1e5847c7d0c78c94c2c51ccc +README.md: 683a774b00a2305b0489a62b1b33f9d20dddffe8 +README.zh.md: 34b828081ebbde7fea70f4dd409e2980c6942eea diff --git a/packages/code-runtime/code-runtime-worker-thread/README.md b/packages/code-runtime/code-runtime-worker-thread/README.md index 40f2c735f1..683a774b00 100644 --- a/packages/code-runtime/code-runtime-worker-thread/README.md +++ b/packages/code-runtime/code-runtime-worker-thread/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-code-runtime-worker-thread` executes TypeScript programs for the [`dsh-code-runtime`](../code-runtime/README.md) seam: each program runs in one fresh Node worker thread with host-provided bindings callable as ordinary async functions, and the run returns `{ value, logs, error? }`. It is the shipped backend for Code Mode in `dsh-tools`, so mounting it is what makes model-written TypeScript execution work in a composition. The runtime contains a program without isolating it: the trust posture is bash-equivalent, with an empty environment, a heap cap, measured busy-time and wall-clock budgets, and hard termination. Programs run once per request with no state carried between runs, and every failure — syntax error, budget expiry, abort, OOM exit, or output overflow — comes back as a result field. +`dsh-code-runtime-worker-thread` executes TypeScript programs for the [`dsh-code-runtime`](../code-runtime/README.md) seam: each program runs in one fresh Node worker thread with host-provided bindings callable as ordinary async functions, and the run returns `{ value, logs, error? }`. It is the shipped backend for PTC mode in `dsh-tools`, so mounting it is what makes model-written TypeScript execution work in a composition. The runtime contains a program without isolating it: the trust posture is bash-equivalent, with an empty environment, a heap cap, measured busy-time and wall-clock budgets, and hard termination. Programs run once per request with no state carried between runs, and every failure — syntax error, budget expiry, abort, OOM exit, or output overflow — comes back as a result field. ## Table of Contents @@ -25,7 +25,7 @@ English | [中文](README.zh.md) ## Use this package -Mount this backend with the code-runtime seam when a composition should execute model-written TypeScript programs; Code Mode in `dsh-tools` then drives it through `ctx.codeRuntime` whenever the model calls `run_code`. Every execution cap is validated config, so you can size the runtime for your deployment from `cordis.yml`. +Mount this backend with the code-runtime seam when a composition should execute model-written TypeScript programs; PTC mode in `dsh-tools` then drives it through `ctx.codeRuntime` whenever the model calls `run_code`. Every execution cap is validated config, so you can size the runtime for your deployment from `cordis.yml`. ### Minimal configuration @@ -50,7 +50,7 @@ Every field is validated and defaulted at load; there are no other tunables. The ### What a run returns -A successful run returns the program's lossless-JSON completion value as `result.value` and the text it printed, in order, as `result.logs`. Top-level `await` and `return` work, and the program can call the host-provided binding functions (Code Mode exposes one `tools` object) as ordinary async calls. +A successful run returns the program's lossless-JSON completion value as `result.value` and the text it printed, in order, as `result.logs`. Top-level `await` and `return` work, and the program can call the host-provided binding functions (PTC mode exposes one `tools` object) as ordinary async calls. ### Containment, not a security boundary @@ -72,7 +72,7 @@ This section explains the design behind the backend; observable behavior is full ### Design concept -The backend rests on one separation: **containment, not a security boundary**. Model code has bash-equivalent trust (the [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) Trust posture), so the design optimizes for reconstructability and bounded resource use rather than for a hard multi-tenant boundary — that awaits a container-class backend. Each run gets one fresh worker, so a program's world dies with its worker: no cross-run state exists to leak or to log, and a run is reconstructable from the session log alone. +The backend rests on one separation: **containment, not a security boundary**. Model code has bash-equivalent trust (the [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) Trust posture), so the design optimizes for reconstructability and bounded resource use rather than for a hard multi-tenant boundary — that awaits a container-class backend. Each run gets one fresh worker, so a program's world dies with its worker: no cross-run state exists to leak or to log, and a run is reconstructable from the session log alone. ### Execution flow @@ -116,7 +116,7 @@ Source mode loads erasable-only `src/worker.ts` through Node's native type strip Read these when the backend contract is not enough. They move from the seam definition to the consumer and the configuration surface. - [Code runtime seam](../code-runtime/README.md) — the abstract contract this backend implements. -- [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) — how `dsh-tools` consumes `ctx.codeRuntime` and presents `run_code`. +- [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) — how `dsh-tools` consumes `ctx.codeRuntime` and presents `run_code`. - [Code runtime subsystem reference](../../../docs/subsystems/code-runtime.md) — request/result vocabulary, bindings, and failure taxonomy. - [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-code-runtime-worker-thread) — every accepted config field and its source declaration. @@ -125,7 +125,7 @@ Read these when the backend contract is not enough. They move from the seam defi ## Model Experience -Indirectly, through Code Mode in `dsh-tools`, which renders the exact outer value when it fits or an explicit `invalid-output` / `output-limit` failure, while only the outer `run_code` result enters model context under its ordinary spill policy and binding traffic plus intermediate values remain execution-local. +Indirectly, through PTC mode in `dsh-tools`, which renders the exact outer value when it fits or an explicit `invalid-output` / `output-limit` failure, while only the outer `run_code` result enters model context under its ordinary spill policy and binding traffic plus intermediate values remain execution-local. #### KV Cache effect diff --git a/packages/code-runtime/code-runtime-worker-thread/README.zh.md b/packages/code-runtime/code-runtime-worker-thread/README.zh.md index b9389d359d..34b828081e 100644 --- a/packages/code-runtime/code-runtime-worker-thread/README.zh.md +++ b/packages/code-runtime/code-runtime-worker-thread/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-code-runtime-worker-thread` 为 [`dsh-code-runtime`](../code-runtime/README.zh.md) seam 执行 TypeScript 程序:每个程序都在一个全新的 Node Worker 线程中运行,宿主提供的绑定可作为普通异步函数调用,运行返回 `{ value, logs, error? }`。它是 `dsh-tools` 中 Code Mode 的已发布后端,因此挂载它正是让模型编写的 TypeScript 执行在组合中生效的方式。运行时「包含」程序,但不隔离它:信任立场与 bash 等价,并带有空环境、堆上限、实测忙碌时间与墙钟预算,以及强制终止。程序每次请求只运行一次,运行之间不保留状态;每个失败——语法错误、预算到期、中止、OOM 退出或输出溢出——都以结果字段返回。 +`dsh-code-runtime-worker-thread` 为 [`dsh-code-runtime`](../code-runtime/README.zh.md) seam 执行 TypeScript 程序:每个程序都在一个全新的 Node Worker 线程中运行,宿主提供的绑定可作为普通异步函数调用,运行返回 `{ value, logs, error? }`。它是 `dsh-tools` 中 PTC mode 的已发布后端,因此挂载它正是让模型编写的 TypeScript 执行在组合中生效的方式。运行时「包含」程序,但不隔离它:信任立场与 bash 等价,并带有空环境、堆上限、实测忙碌时间与墙钟预算,以及强制终止。程序每次请求只运行一次,运行之间不保留状态;每个失败——语法错误、预算到期、中止、OOM 退出或输出溢出——都以结果字段返回。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -当组合需要执行模型编写的 TypeScript 程序时,连同 code-runtime seam 一起挂载此后端;只要模型调用 `run_code`,`dsh-tools` 中的 Code Mode 就会通过 `ctx.codeRuntime` 驱动它。每个执行上限都是已验证的配置,因此你可以从 `cordis.yml` 为部署调整运行时规模。 +当组合需要执行模型编写的 TypeScript 程序时,连同 code-runtime seam 一起挂载此后端;只要模型调用 `run_code`,`dsh-tools` 中的 PTC mode 就会通过 `ctx.codeRuntime` 驱动它。每个执行上限都是已验证的配置,因此你可以从 `cordis.yml` 为部署调整运行时规模。 ### 最小配置 @@ -50,7 +50,7 @@ kind: "package-reference" ### 运行返回什么 -成功的运行把程序的无损 JSON 完成值作为 `result.value` 返回,把程序打印的文本按顺序作为 `result.logs` 返回。顶层 `await`/`return` 可用,程序可以把宿主提供的绑定函数(Code Mode 暴露一个 `tools` 对象)当作普通异步调用。 +成功的运行把程序的无损 JSON 完成值作为 `result.value` 返回,把程序打印的文本按顺序作为 `result.logs` 返回。顶层 `await`/`return` 可用,程序可以把宿主提供的绑定函数(PTC mode 暴露一个 `tools` 对象)当作普通异步调用。 ### 包含而非安全边界 @@ -72,7 +72,7 @@ kind: "package-reference" ### 设计理念 -后端建立在一个分离之上:**包含,而非安全边界**。模型代码拥有与 bash 等价的信任([Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md) 的 Trust posture),因此设计追求可重建性与有界资源使用,而非硬性的多租户边界——那需要等待容器级后端。每次运行使用一个全新的 worker,程序的世界随 worker 一同终止:不存在可泄漏、也无需记录的跨运行状态,仅凭会话日志即可重建一次运行。 +后端建立在一个分离之上:**包含,而非安全边界**。模型代码拥有与 bash 等价的信任([PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md) 的 Trust posture),因此设计追求可重建性与有界资源使用,而非硬性的多租户边界——那需要等待容器级后端。每次运行使用一个全新的 worker,程序的世界随 worker 一同终止:不存在可泄漏、也无需记录的跨运行状态,仅凭会话日志即可重建一次运行。 ### 执行流程 @@ -116,7 +116,7 @@ kind: "package-reference" 当后端约定不够用时阅读以下内容。它们从 seam 定义进入消费方与配置面。 - [代码运行时 seam](../code-runtime/README.zh.md)——此后端实现的抽象约定。 -- [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md)——`dsh-tools` 如何消费 `ctx.codeRuntime` 并呈现 `run_code`。 +- [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md)——`dsh-tools` 如何消费 `ctx.codeRuntime` 并呈现 `run_code`。 - [代码运行时子系统参考](../../../docs/subsystems/code-runtime.zh.md)——请求/结果词汇、绑定与失败分类体系。 - [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-code-runtime-worker-thread)——每个受支持配置字段及其源声明。 @@ -125,7 +125,7 @@ kind: "package-reference" ## 模型体验 -通过 `dsh-tools` 中的 Code Mode 间接提供,如果外层值能容纳则原样渲染,否则返回明确的 `invalid-output`/`output-limit` 失败,且只有外层 `run_code` 结果在其普通落盘策略下进入模型上下文,绑定通信与中间值始终只存在于执行环境中。 +通过 `dsh-tools` 中的 PTC mode 间接提供,如果外层值能容纳则原样渲染,否则返回明确的 `invalid-output`/`output-limit` 失败,且只有外层 `run_code` 结果在其普通落盘策略下进入模型上下文,绑定通信与中间值始终只存在于执行环境中。 #### KV Cache 影响 diff --git a/packages/code-runtime/code-runtime/README.i18n.yaml b/packages/code-runtime/code-runtime/README.i18n.yaml index cdf019ea55..54afe6235e 100644 --- a/packages/code-runtime/code-runtime/README.i18n.yaml +++ b/packages/code-runtime/code-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/code-runtime/code-runtime/README.md -README.md: 7cb07be0871bd585d40f9331a12827c92f4b87c7 -README.zh.md: 4881256012d0208707ec2ec2dbe3ba4391d587cf +README.md: e3d43e7add4992c44fef966651f91addb81aa1eb +README.zh.md: bcbeaa8bbdfee33baa1b29e232038e2bd6b77728 diff --git a/packages/code-runtime/code-runtime/README.md b/packages/code-runtime/code-runtime/README.md index 7cb07be087..e3d43e7add 100644 --- a/packages/code-runtime/code-runtime/README.md +++ b/packages/code-runtime/code-runtime/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-code-runtime` defines what a code runtime does: run one model-written program against a set of host-provided async functions and report `{ value, logs, error? }` — without dictating how any backend implements it. Load it in a composition with a backend and the service is available as `ctx.codeRuntime`; Code Mode in `dsh-tools` then runs model-written programs that compose tools. Every request runs once with no state carried between runs, and every program outcome — including failures — resolves as a result field rather than a rejection. The runtime knows nothing about tools or sessions: it is handed a program and named bindings, and everything tool-shaped stays with the consumer. +`dsh-code-runtime` defines what a code runtime does: run one model-written program against a set of host-provided async functions and report `{ value, logs, error? }` — without dictating how any backend implements it. Load it in a composition with a backend and the service is available as `ctx.codeRuntime`; PTC mode in `dsh-tools` then runs model-written programs that compose tools. Every request runs once with no state carried between runs, and every program outcome — including failures — resolves as a result field rather than a rejection. The runtime knows nothing about tools or sessions: it is handed a program and named bindings, and everything tool-shaped stays with the consumer. ## Table of Contents @@ -25,11 +25,11 @@ English | [中文](README.zh.md) ## Use this package -Choose this package when you compose a deployment that executes model-written programs, consume `ctx.codeRuntime` directly, or build a backend that runs programs. In the shipped composition, Code Mode in `dsh-tools` is the consumer: only what the program printed and returned re-enters the conversation. +Choose this package when you compose a deployment that executes model-written programs, consume `ctx.codeRuntime` directly, or build a backend that runs programs. In the shipped composition, PTC mode in `dsh-tools` is the consumer: only what the program printed and returned re-enters the conversation. ### Run a program -Give the runtime a program source and one or more binding namespaces. Each namespace becomes one global object of async functions inside the program — Code Mode passes one under `tools`. The program runs as the body of an async function, so top-level `await` and `return` work; a lossless-JSON completion becomes `result.value`, emitted text arrives in order as `result.logs`, and any failure is reported in `result.error` with a kind you can branch on. The runtime never rejects for a program failure — rejection means you misused the seam, for example by submitting a run after disposal. +Give the runtime a program source and one or more binding namespaces. Each namespace becomes one global object of async functions inside the program — PTC mode passes one under `tools`. The program runs as the body of an async function, so top-level `await` and `return` work; a lossless-JSON completion becomes `result.value`, emitted text arrives in order as `result.logs`, and any failure is reported in `result.error` with a kind you can branch on. The runtime never rejects for a program failure — rejection means you misused the seam, for example by submitting a run after disposal. ```text const result = await ctx.codeRuntime.run({ @@ -63,7 +63,7 @@ This section explains the design behind the seam; observable behavior is fully c ### Design concept -The package is the Service Definition role of the code-execution capability seam ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract `CodeRuntime extends Service` registered as `ctx.codeRuntime`, plus the vocabulary both backends and the consumer share. Providers subclass `CodeRuntime`, implement `run`, and register the service; the consumer (Code Mode in `dsh-tools`) generates the model-facing SDK and bridges tool dispatch. The runtime stays ignorant of tools and sessions by contract: it receives a program and named async bindings and returns `{ value, logs, error? }`. +The package is the Service Definition role of the code-execution capability seam ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract `CodeRuntime extends Service` registered as `ctx.codeRuntime`, plus the vocabulary both backends and the consumer share. Providers subclass `CodeRuntime`, implement `run`, and register the service; the consumer (PTC mode in `dsh-tools`) generates the model-facing SDK and bridges tool dispatch. The runtime stays ignorant of tools and sessions by contract: it receives a program and named async bindings and returns `{ value, logs, error? }`. ### Service API @@ -94,9 +94,9 @@ Binding-global and error-class names are language-portable: they must match the ## Further Exploration -Read these when the package-level contract is not enough. They move from the Code Mode consumer to the shipped backends and the capability-seam model. +Read these when the package-level contract is not enough. They move from the PTC mode consumer to the shipped backends and the capability-seam model. -- [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) — how the tool registry consumes `ctx.codeRuntime` and presents `run_code` to the model. +- [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.md) — how the tool registry consumes `ctx.codeRuntime` and presents `run_code` to the model. - [Worker-thread backend](../code-runtime-worker-thread/README.md) — the shipped TypeScript execution backend. - [Python protocol package](../code-runtime-python/README.md) — the wire protocol for the CPython backend. - [Code runtime subsystem reference](../../../docs/subsystems/code-runtime.md) — request/result vocabulary, bindings, and the `ctx.codeRuntime` cordis surface. @@ -107,7 +107,7 @@ Read these when the package-level contract is not enough. They move from the Cod ## Model Experience -Indirectly, through Code Mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. +Indirectly, through PTC mode in `dsh-tools`, which exposes `run_code` and returns program logs, values, or failures as retained tool-result tokens. #### KV Cache effect diff --git a/packages/code-runtime/code-runtime/README.zh.md b/packages/code-runtime/code-runtime/README.zh.md index 4881256012..bcbeaa8bbd 100644 --- a/packages/code-runtime/code-runtime/README.zh.md +++ b/packages/code-runtime/code-runtime/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-code-runtime` 定义代码运行时做什么:针对一组宿主提供的异步函数运行一段模型编写的程序,并报告 `{ value, logs, error? }`——不规定任何后端如何实现。在组合中与一个后端一起加载它,服务即可作为 `ctx.codeRuntime` 使用;随后 `dsh-tools` 中的 Code Mode 即可运行组合工具的模型程序。每次请求只运行一次,运行之间不保留状态;每个程序结果——包括失败——都以结果字段 resolve,而不是 reject。运行时不了解工具或会话:调用方只向它提供程序与具名绑定,所有与工具有关的内容都留在 Consumer。 +`dsh-code-runtime` 定义代码运行时做什么:针对一组宿主提供的异步函数运行一段模型编写的程序,并报告 `{ value, logs, error? }`——不规定任何后端如何实现。在组合中与一个后端一起加载它,服务即可作为 `ctx.codeRuntime` 使用;随后 `dsh-tools` 中的 PTC mode 即可运行组合工具的模型程序。每次请求只运行一次,运行之间不保留状态;每个程序结果——包括失败——都以结果字段 resolve,而不是 reject。运行时不了解工具或会话:调用方只向它提供程序与具名绑定,所有与工具有关的内容都留在 Consumer。 ## 目录 @@ -25,11 +25,11 @@ kind: "package-reference" ## 使用本包 -当你要组合一个执行模型程序的部署、直接消费 `ctx.codeRuntime`,或构建运行程序的后端时,选择本包。在已发布的组合中,`dsh-tools` 里的 Code Mode 是消费方:只有程序打印和返回的内容重新进入对话。 +当你要组合一个执行模型程序的部署、直接消费 `ctx.codeRuntime`,或构建运行程序的后端时,选择本包。在已发布的组合中,`dsh-tools` 里的 PTC mode 是消费方:只有程序打印和返回的内容重新进入对话。 ### 运行一个程序 -向运行时提供程序源码与一个或多个绑定命名空间。每个命名空间会成为程序内的一个全局异步函数对象——Code Mode 在 `tools` 下传入一个。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用;无损 JSON 完成值成为 `result.value`,输出的文本按顺序进入 `result.logs`,任何失败都以 `result.error` 报告并带有可分支的 kind。运行时绝不会因程序失败而 reject——reject 意味着你误用了 seam,例如在 dispose(资源释放)后提交运行。 +向运行时提供程序源码与一个或多个绑定命名空间。每个命名空间会成为程序内的一个全局异步函数对象——PTC mode 在 `tools` 下传入一个。程序作为异步函数的函数体运行,因此顶层 `await`/`return` 可用;无损 JSON 完成值成为 `result.value`,输出的文本按顺序进入 `result.logs`,任何失败都以 `result.error` 报告并带有可分支的 kind。运行时绝不会因程序失败而 reject——reject 意味着你误用了 seam,例如在 dispose(资源释放)后提交运行。 ```text const result = await ctx.codeRuntime.run({ @@ -63,7 +63,7 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配 `[A-Za ### 设计理念 -本包是代码执行能力 seam 的 Service Definition 角色([能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)):一个注册为 `ctx.codeRuntime` 的抽象 `CodeRuntime extends Service`,加上两个后端与消费方共享的词汇。提供方继承 `CodeRuntime`、实现 `run` 并注册服务;消费方(`dsh-tools` 中的 Code Mode)生成面向模型的 SDK 并桥接工具分发。按约定,运行时不了解工具与会话:它接收程序与具名异步绑定,返回 `{ value, logs, error? }`。 +本包是代码执行能力 seam 的 Service Definition 角色([能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)):一个注册为 `ctx.codeRuntime` 的抽象 `CodeRuntime extends Service`,加上两个后端与消费方共享的词汇。提供方继承 `CodeRuntime`、实现 `run` 并注册服务;消费方(`dsh-tools` 中的 PTC mode)生成面向模型的 SDK 并桥接工具分发。按约定,运行时不了解工具与会话:它接收程序与具名异步绑定,返回 `{ value, logs, error? }`。 ### 服务 API @@ -94,9 +94,9 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配标识 ## 进一步探索 -当包级约定不够用时阅读以下内容。它们从 Code Mode 消费方进入已发布的后端与能力 seam 模型。 +当包级约定不够用时阅读以下内容。它们从 PTC mode 消费方进入已发布的后端与能力 seam 模型。 -- [Code Mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-code-mode.zh.md)——工具注册表如何消费 `ctx.codeRuntime` 并把 `run_code` 呈现给模型。 +- [PTC mode Agent Note](../../../.agents/notes/implemented/feature/2026-06-15-ptc.zh.md)——工具注册表如何消费 `ctx.codeRuntime` 并把 `run_code` 呈现给模型。 - [Worker 线程后端](../code-runtime-worker-thread/README.zh.md)——已发布的 TypeScript 执行后端。 - [Python 协议包](../code-runtime-python/README.zh.md)——CPython 后端的协议格式。 - [代码运行时子系统参考](../../../docs/subsystems/code-runtime.zh.md)——请求/结果词汇、绑定与 `ctx.codeRuntime` 的 cordis 接口面。 @@ -107,7 +107,7 @@ binding-global 与 error-class 名称是语言可移植的:必须匹配标识 ## 模型体验 -通过 `dsh-tools` 中的 Code Mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。 +通过 `dsh-tools` 中的 PTC mode 间接提供;后者公开 `run_code`,并将程序日志、值或失败作为保留的工具结果 token 返回。 #### KV Cache 影响 diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index b83ce5e8e4..19102ec246 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/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/context/agent-instructions/README.md -README.md: 903f054bd53a423e0d57970d72d3144795d2e884 -README.zh.md: 447b4843dd4c92b938261f5d85b702c51eb9bff7 +README.md: 798134a9e11a4e2cf6337f1f7a2d9c79410860eb +README.zh.md: d187494ec0f6a907df1907d6420f589792beff8b diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index 903f054bd5..798134a9e1 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -172,7 +172,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when #### Token effect -Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and Code Mode defers the same message until after the outer `run_code` result and its enclosing durable step. +Each discovered scope adds bounded history tokens until compaction. Unchanged content is suppressed by visible session state plus version/digest comparison, and PTC mode defers the same message until after the outer `run_code` result and its enclosing durable step. #### KV Cache effect diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index 447b4843dd..d187494ec0 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -172,7 +172,7 @@ These instructions apply to work under `packages/app`. Use them as guidance when #### Token 影响 -每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,Code Mode 将同一消息延迟至外层 `run_code` 结果及其所属持久步骤之后。 +每个已发现 scope 都会添加有界历史 token,直到压缩。可见会话状态与版本/digest 比较会抑制未更改内容,PTC mode 将同一消息延迟至外层 `run_code` 结果及其所属持久步骤之后。 #### KV Cache 影响 diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index cc98669766..7a16de9ab8 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/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-presentation/README.md -README.md: 5be7a596f6773ea29543758e624f68d0f6be3ffc -README.zh.md: 52cec9b1958699c15699c8c29cf5f4b3f0f78d91 +README.md: 425a652f29dfbc99978c3f29839a099f1d9a1c52 +README.zh.md: 859045c12ca532cea2f5eb8a463c4e58662d51c2 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index 5be7a596f6..425a652f29 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -An [agent preset](../../preset/agent-presets/README.md) carries `dsh-agent-tool-presentation` to choose which form of its tools the model sees: `native` (every visible schema), `code` (only `run_code` plus a generated SDK), or `both`. The tool registry itself stays on the host plane — this row only declares the presentation for the mounting agent, so a Code Mode session runs beside native ones in one process, each seeing its own catalog. A code mode waits for a code runtime before mounting, so a preset selecting Code Mode against a deployment without one fails at mount instead of at the first prompt. The `mode` field is required: a preset without this row already gets the deployment default. Choose it when an agent preset needs to fix the tool form its agents' models see. +An [agent preset](../../preset/agent-presets/README.md) carries `dsh-agent-tool-presentation` to choose which form of its tools the model sees: `native` (every visible schema), `code` (only `run_code` plus a generated SDK), or `both`. The tool registry itself stays on the host plane — this row only declares the presentation for the mounting agent, so a PTC mode session runs beside native ones in one process, each seeing its own catalog. A PTC mode waits for a code runtime before mounting, so a preset selecting PTC mode against a deployment without one fails at mount instead of at the first prompt. The `mode` field is required: a preset without this row already gets the deployment default. Choose it when an agent preset needs to fix the tool form its agents' models see. ## Table of Contents @@ -32,7 +32,7 @@ Add this row to an agent preset to fix how every agent joined to that preset see ```yaml - name: '@deepseek-ai/dsh-agent-tool-presentation' config: - mode: code + mode: ptc ``` | Field | Default | Meaning | @@ -41,9 +41,9 @@ Add this row to an agent preset to fix how every agent joined to that preset see The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-tool-presentation) is the exhaustive source for every accepted field. `mode` is required rather than defaulted because a preset without this row inherits the deployment default. -### What code mode requires +### What PTC mode requires -Selecting `code` or `both` needs a composed code runtime (`ctx.codeRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tools`. A preset that selects a code mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. +Selecting `code` or `both` needs a composed code runtime (`ctx.codeRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tools`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. ### One presentation per agent @@ -61,7 +61,7 @@ This section explains how the package realizes the behavior above; the observabl ### Design concept -The tool registry cannot move into a preset: its consumers are all host-plane — the agent loop reads its scheduler, the API proxy reads its presenters, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. What a preset can own is the presentation of that registry. `ctx.tools.presentAs()` declares it for the mounting scope, which is the preset's standing mount, so the declaration covers every agent joined to that preset and a Code Mode preset runs beside native ones in one process. One row per composition, not one per session. +The tool registry cannot move into a preset: its consumers are all host-plane — the agent loop reads its scheduler, the API proxy reads its presenters, and every tool plugin registers into it — and a service only moves down when all of its consumers move with it. What a preset can own is the presentation of that registry. `ctx.tools.presentAs()` declares it for the mounting scope, which is the preset's standing mount, so the declaration covers every agent joined to that preset and a PTC mode preset runs beside native ones in one process. One row per composition, not one per session. ### Source map @@ -72,7 +72,7 @@ The tool registry cannot move into a preset: its consumers are all host-plane ### Behavior notes -`native` applies immediately. A code mode instead waits for `ctx.codeRuntime`, a host-plane service: a preset selecting Code Mode against a deployment composing no runtime holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. `presentAs` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it. +`native` applies immediately. A PTC mode instead waits for `ctx.codeRuntime`, a host-plane service: a preset selecting PTC mode against a deployment composing no runtime holds this row pending, and `dsh-agent-presets` refuses the mount naming this id. `presentAs` is itself the effect, so the declaration unwinds with this row without a second wrapper owning it. @@ -85,8 +85,8 @@ The package-level contract is enough for most consumers; read these when you nee - [tools package](../tools/README.md) — the tool presentation modes and `presentAs` API. - [agent-presets package](../../preset/agent-presets/README.md) — how presets compose agents and their standing mounts. -- [code-runtime worker-thread package](../../code-runtime/code-runtime-worker-thread/README.md) — the TypeScript runtime a code mode needs. -- [Code Mode executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md) — why the announced and callable surfaces stay the same. +- [code-runtime worker-thread package](../../code-runtime/code-runtime-worker-thread/README.md) — the TypeScript runtime a PTC mode needs. +- [PTC mode executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md) — why the announced and callable surfaces stay the same. - [Core group map](../README.md) — how the core packages compose. ----- @@ -107,7 +107,7 @@ No direct invalidation; the presentation is fixed when the agent is composed, so These limits define when this row needs special care. They are current package constraints, not a task backlog. -- **The runtime stays host-plane** — a preset can select Code Mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no code-mode preset. +- **The runtime stays host-plane** — a preset can select PTC mode but cannot supply the TypeScript runtime it needs; a deployment that composes none can compose no ptc preset. ### Dev Note diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index 52cec9b195..859045c12c 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -[agent preset](../../preset/agent-presets/README.zh.md) 携带 `dsh-agent-tool-presentation`,用来声明「模型看到其工具的哪一种形态」:`native`(每个可见 schema)、`code`(只有 `run_code` 加一份生成的 SDK)或 `both`。工具注册表本身仍在宿主平面——这一行只声明挂载 agent 的呈现方式,因此一个 Code Mode 会话可以与多个 native 会话同进程并存,各自看到各自的目录。code 类模式在挂载前会等待代码运行时,因此针对未组装运行时的部署选择 Code Mode 的 preset 会在挂载时失败,而不是在第一次请求时失败。`mode` 字段是必填的:不带这一行的 preset 本来就会拿到部署默认值。当 agent preset 需要固定其 agent 的模型所看到的工具形态时,请选择本包。 +[agent preset](../../preset/agent-presets/README.zh.md) 携带 `dsh-agent-tool-presentation`,用来声明「模型看到其工具的哪一种形态」:`native`(每个可见 schema)、`code`(只有 `run_code` 加一份生成的 SDK)或 `both`。工具注册表本身仍在宿主平面——这一行只声明挂载 agent 的呈现方式,因此一个 PTC mode 会话可以与多个 native 会话同进程并存,各自看到各自的目录。code 类模式在挂载前会等待代码运行时,因此针对未组装运行时的部署选择 PTC mode 的 preset 会在挂载时失败,而不是在第一次请求时失败。`mode` 字段是必填的:不带这一行的 preset 本来就会拿到部署默认值。当 agent preset 需要固定其 agent 的模型所看到的工具形态时,请选择本包。 ## 目录 @@ -32,7 +32,7 @@ kind: "package-reference" ```yaml - name: '@deepseek-ai/dsh-agent-tool-presentation' config: - mode: code + mode: ptc ``` | 字段 | 默认值 | 含义 | @@ -41,7 +41,7 @@ kind: "package-reference" 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-tool-presentation)是每个受支持字段的穷尽式真源。`mode` 是必填而非有默认值,因为不带这一行的 preset 会继承部署默认值。 -### code 模式需要什么 +### PTC 模式需要什么 选择 `code` 或 `both` 需要已组合的代码运行时(`ctx.codeRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tools` 中。针对未组装此类运行时的部署选择 code 类模式的 preset 会拒绝挂载并点名这一行,使失败落在操作者可以行动的地方,而不是落在会话的第一次请求上。 @@ -61,7 +61,7 @@ kind: "package-reference" ### 设计理念 -工具注册表搬不进 preset:它的消费者全在宿主平面——agent loop 读它的调度器,API proxy 读它的 presenter,每个工具插件都往里注册——而一个服务只有在所有消费者一起下沉时才能下沉。preset 能拥有的是这份注册表的呈现方式。`ctx.tools.presentAs()` 为挂载作用域声明它,而挂载作用域就是 preset 的常驻挂载,因此该声明覆盖每个加入该 preset 的 agent,一个 Code Mode preset 可以与多个 native 会话同进程并存。每个组合一行,而不是每个会话一行。 +工具注册表搬不进 preset:它的消费者全在宿主平面——agent loop 读它的调度器,API proxy 读它的 presenter,每个工具插件都往里注册——而一个服务只有在所有消费者一起下沉时才能下沉。preset 能拥有的是这份注册表的呈现方式。`ctx.tools.presentAs()` 为挂载作用域声明它,而挂载作用域就是 preset 的常驻挂载,因此该声明覆盖每个加入该 preset 的 agent,一个 PTC mode preset 可以与多个 native 会话同进程并存。每个组合一行,而不是每个会话一行。 ### 源码地图 @@ -72,7 +72,7 @@ kind: "package-reference" ### 行为说明 -`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 Code Mode 的 preset 会让这一行停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 +`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 @@ -85,8 +85,8 @@ kind: "package-reference" - [tools 包](../tools/README.zh.md)——工具呈现模式与 `presentAs` API。 - [agent-presets 包](../../preset/agent-presets/README.zh.md)——preset 如何组合 agent 及其常驻挂载。 -- [code-runtime worker-thread 包](../../code-runtime/code-runtime-worker-thread/README.zh.md)——code 模式所需的 TypeScript 运行时。 -- [Code Mode 执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md)——通告面与可调用面为何保持一致。 +- [code-runtime worker-thread 包](../../code-runtime/code-runtime-worker-thread/README.zh.md)——PTC 模式所需的 TypeScript 运行时。 +- [PTC mode 执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md)——通告面与可调用面为何保持一致。 - [core 分组地图](../README.zh.md)——core 各包如何组合。 ----- @@ -107,7 +107,7 @@ kind: "package-reference" 这些限制说明这一行何时需要特别留意。它们是当前包约束,不是任务积压。 -- **运行时仍在宿主平面**——preset 可以选择 Code Mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 code 模式的 preset。 +- **运行时仍在宿主平面**——preset 可以选择 PTC mode,却无法自带它所需的 TypeScript 运行时;未组装运行时的部署也就无法组装任何 PTC 模式的 preset。 ### 开发备注 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index ed62ac8314..0ab3633c91 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -With `dsh-tools`, tool plugins register schemas and executors, and every model tool call runs through a guarded pipeline — allow/deny/ask policy, monotonic guards, around-dispatch wrappers, result inspection, definition-owned content finalization, and a final observe-only notification. The package also controls how tools are presented to the model: its `mode` config selects native function calling, [Code Mode](#code-mode), or both, and one agent shadows that default for itself with `presentAs`. Tool authors use `defineTool` for typed parameter and output schemas, an optional cooperative timeout, parallel-safety classification, and optional UI presentation intents. Choose it as the registry for any capability you want the model to reach — schemas flow into prompt assembly automatically. +With `dsh-tools`, tool plugins register schemas and executors, and every model tool call runs through a guarded pipeline — allow/deny/ask policy, monotonic guards, around-dispatch wrappers, result inspection, definition-owned content finalization, and a final observe-only notification. The package also controls how tools are presented to the model: its `mode` config selects native function calling, [PTC mode](#ptc-mode), or both, and one agent shadows that default for itself with `presentAs`. Tool authors use `defineTool` for typed parameter and output schemas, an optional cooperative timeout, parallel-safety classification, and optional UI presentation intents. Choose it as the registry for any capability you want the model to reach — schemas flow into prompt assembly automatically. ## Table of Contents @@ -111,7 +111,7 @@ The registry holds typed `ToolDefinition`s in scoped layers and projects them on | [`src/schema.ts`](src/schema.ts) | The `defineTool` DSL: `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs` | | [`src/json-schema.ts`](src/json-schema.ts) | The enforced raw JSON Schema subset and validation | | [`src/presentation.ts`](src/presentation.ts) | The `card`-tagged UI render intents | -| [`src/code-mode.ts`](src/code-mode.ts) | Code Mode: SDK generation, `run_code` dispatch bridge, settlement | +| [`src/ptc.ts`](src/ptc.ts) | PTC mode: SDK generation, `run_code` dispatch bridge, settlement | | [`src/ts-types.ts`](src/ts-types.ts) | TypeScript SDK type rendering | | [`src/py-types.ts`](src/py-types.ts) | Python SDK type rendering | | [`src/invariant.ts`](src/invariant.ts) | Invariant companion | @@ -120,9 +120,9 @@ The registry holds typed `ToolDefinition`s in scoped layers and projects them on Each typed invocation materializes and freezes parsed arguments, assigns an opaque correlation token, and runs policy and dispatch. Cancellation is cooperative and quiescent: every tool body receives the caller-owned `exec.signal` and must observe it; cancellation before body invocation is `ABORTED_BEFORE_DISPATCH`, after invocation it replaces only a successful outcome with `ABORTED`. Denials, wrapper failures, tool failures, post-policy failures, and timeout-owned `TOOL_TIMEOUT` remain more specific. Unknown and throwing tools become structured errors (`UNKNOWN_TOOL`), so a call fails without ending the turn. -### Code Mode +### PTC mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding call re-enters the complete tool pipeline with logged correlation to the outer call, scheduled through a per-run pool that reuses the native concurrency contract. Under `code` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. The [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.md) owns the collapse contract. +Under `code` or `both`, the registry exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding call re-enters the complete tool pipeline with logged correlation to the outer call, scheduled through a per-run pool that reuses the native concurrency contract. Under `code` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. The [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md) owns the collapse contract. ### Extension points @@ -164,13 +164,13 @@ Fixed per-request cost proportional to the visible definitions. Restrictions tha Prefix-stable while visible definitions and their order are unchanged. Registration, disposal, or scoped restriction may invalidate reuse from the first changed schema token. -### Code Mode schema and system prompt +### PTC mode schema and system prompt #### 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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this Code Mode API; under `code` the prompt also carries the `tools:code-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. +PTC 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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `code` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. -##### TypeScript Code Mode SDK instructions with bash +##### TypeScript PTC mode SDK instructions with bash ```markdown ## Writing code for run_code @@ -191,17 +191,17 @@ Program-only SDK bindings: #### Token effect -Fixed per-request cost proportional to the visible definitions. Code Mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. +Fixed per-request cost proportional to the visible definitions. PTC mode trades end-tool schemas for generated SDK text plus one transport schema rather than promising a universal reduction. #### KV Cache effect -Prefix-stable while the Code Mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. +Prefix-stable while the PTC mode selection, generated SDK, transport schema, and visible tool set are unchanged. Mode or filter changes may invalidate reuse from the first changed prompt or schema token. ### Tool-call history and results #### What the model sees -The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. Code Mode renders the outer program's printed lines and return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only, while a successful image-bearing sub-result is appended after the outer result as source-attributed context. +The loop retains model-emitted arguments and the registry's final content. Any thrown or denied call becomes exactly `Error: `. PTC mode renders the outer program's printed lines and return value, `(run_code completed with no output)` when both are empty, or `Error: code run failed (): ` followed conditionally by `Captured output:` and the captured lines. Inner dispatch events stay log-only, while a successful image-bearing sub-result is appended after the outer result as source-attributed context. #### Token effect @@ -222,8 +222,8 @@ These limits define when the registry needs special care. They are current packa - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-tool-call-timeout-policy` wrapper. -- **Code Mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is code-only. -- **Code Mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. +- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: ptc`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is code-only. +- **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP, because cross-call state would be invisible to the log. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 1c42cbffe2..48b5a123c3 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -使用 `dsh-tools`,工具插件注册 schema 与执行器,每次模型工具调用都经过一条受守卫的流水线——允许/拒绝/询问策略、单调守卫、环绕分发包装层、结果检查、由工具定义持有的内容终结,以及最终的仅观测通知。该包还控制工具向模型呈现的方式:`mode` 配置选择原生 Function Calling(函数调用)、[Code Mode](#code-mode) 或两者,单个 agent 可用 `presentAs` 为自己遮蔽该默认值。工具作者使用 `defineTool` 定义类型化参数与输出 schema、可选的协作式超时、并行安全分类与可选的 UI 呈现意图。把任何希望模型触达的能力做成注册表时请选择本包——schema 会自动流入提示词组装。 +使用 `dsh-tools`,工具插件注册 schema 与执行器,每次模型工具调用都经过一条受守卫的流水线——允许/拒绝/询问策略、单调守卫、环绕分发包装层、结果检查、由工具定义持有的内容终结,以及最终的仅观测通知。该包还控制工具向模型呈现的方式:`mode` 配置选择原生 Function Calling(函数调用)、[PTC mode](#ptc-mode) 或两者,单个 agent 可用 `presentAs` 为自己遮蔽该默认值。工具作者使用 `defineTool` 定义类型化参数与输出 schema、可选的协作式超时、并行安全分类与可选的 UI 呈现意图。把任何希望模型触达的能力做成注册表时请选择本包——schema 会自动流入提示词组装。 ## 目录 @@ -111,7 +111,7 @@ ctx.tools.register(defineTool({ | [`src/schema.ts`](src/schema.ts) | `defineTool` DSL:`ValueSchemaSpec`、`ParameterSchemaSpec`、`InferValue`、`InferArgs` | | [`src/json-schema.ts`](src/json-schema.ts) | 强制执行的原始 JSON Schema 子集与校验 | | [`src/presentation.ts`](src/presentation.ts) | 带 `card` 标签的 UI 呈现意图 | -| [`src/code-mode.ts`](src/code-mode.ts) | Code Mode:SDK 生成、`run_code` 分发桥接层、结算 | +| [`src/ptc.ts`](src/ptc.ts) | PTC mode:SDK 生成、`run_code` 分发桥接层、结算 | | [`src/ts-types.ts`](src/ts-types.ts) | TypeScript SDK 类型渲染 | | [`src/py-types.ts`](src/py-types.ts) | Python SDK 类型渲染 | | [`src/invariant.ts`](src/invariant.ts) | 不变式配套 | @@ -120,9 +120,9 @@ ctx.tools.register(defineTool({ 每次类型化调用都会实体化并冻结解析后的参数、分配不透明关联 token,再运行策略与分发。取消采用协作式并等待完全停稳:每个工具主体都收到调用方拥有的 `exec.signal` 且必须观测它;调用主体前的取消为 `ABORTED_BEFORE_DISPATCH`,调用主体后的取消只能把成功结果替换为 `ABORTED`。拒绝、包装层失败、工具失败、后置策略失败与超时产生的 `TOOL_TIMEOUT` 仍保留更具体的结果。未知工具与抛出异常的工具都会变成结构化错误(`UNKNOWN_TOOL`),因此调用会失败而不会结束轮次。 -### Code Mode +### PTC mode -在 `code` 或 `both` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定调用都会在日志中与外层调用关联,重新进入完整工具流水线,并通过复用原生并发约定的每次运行独有池调度。在纯 `code` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-code-mode-executor-collapse.zh.md) 拥有该收束约定。 +在 `code` 或 `both` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定调用都会在日志中与外层调用关联,重新进入完整工具流水线,并通过复用原生并发约定的每次运行独有池调度。在纯 `code` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md) 拥有该收束约定。 ### 扩展点 @@ -164,13 +164,13 @@ ctx.tools.register(defineTool({ 只要可见定义及其顺序不变,前缀就保持稳定。注册、dispose 或作用域限制可能从第一个改变的 schema token 起使复用失效。 -### Code Mode schema 与系统提示词 +### PTC mode schema 与系统提示词 #### 模型看到什么 -Code Mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 Code Mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:code-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 +PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 -##### 带 bash 的 TypeScript Code Mode SDK 说明 +##### 带 bash 的 TypeScript PTC mode SDK 说明 ```markdown ## Writing code for run_code @@ -191,17 +191,17 @@ Program-only SDK bindings: #### Token 影响 -每次请求的固定成本与可见定义成正比。Code Mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 +每次请求的固定成本与可见定义成正比。PTC mode 使用生成的 SDK 文本加一个传输 schema 取代最终工具 schema,但不承诺普遍减少成本。 #### KV Cache 影响 -只要 Code Mode 选择、生成的 SDK、传输 schema 与可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 +只要 PTC mode 选择、生成的 SDK、传输 schema 与可见工具集合不变,前缀就保持稳定。模式或筛选器变更可能从第一个改变的提示词或 schema token 起使复用失效。 ### 工具调用历史与结果 #### 模型看到什么 -循环会保留模型发出的参数与注册表的最终内容。任何抛出异常或遭到拒绝的调用,都会转换为确切的 `Error: `。Code Mode 只返回外层程序打印的行与呈现后的返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (): `,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中;成功且含图片的子结果会在外层结果之后作为带来源归属的上下文追加。 +循环会保留模型发出的参数与注册表的最终内容。任何抛出异常或遭到拒绝的调用,都会转换为确切的 `Error: `。PTC mode 只返回外层程序打印的行与呈现后的返回值;两者都为空时返回 `(run_code completed with no output)`;失败时返回 `Error: code run failed (): `,并根据是否存在已捕获内容,在其后附加 `Captured output:` 与捕获的行。内部分发事件只保留在日志中;成功且含图片的子结果会在外层结果之后作为带来源归属的上下文追加。 #### Token 影响 @@ -222,8 +222,8 @@ Program-only SDK bindings: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录与呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇与工具输出支持任意 JSON 根。 - **定义中的 `timeoutMs` 仅作声明之用**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。 -- **Code Mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: code`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。 -- **Code Mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 +- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: ptc`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。 +- **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核,因为跨调用状态不会出现在日志中。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index bbaf2ec214..d6dd65615e 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 54114f1f5bb6900a0e96c09730b2fe88ceb92f20 -README.zh.md: 2dbc244cec7744f33da85c6d4361f3f9acc4d8b7 +README.md: 727effb9f6c9c4b1b5af6c7f3da8aea50b0641fa +README.zh.md: df84c48e8271cdddcbf68e7659e8014e0c223ae1 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 54114f1f5b..727effb9f6 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -64,7 +64,7 @@ The smallest working setup mounts the bundle with a workspace-context budget, pl | `includeRuntimeContext` | `true` | whether the agent's history includes dynamic runtime-context snapshots | | `persona` | `''` | the deployment persona text in the system prompt | | `toolOrder` | lexicographic | the order the model sees tools in | -| `tools` | `{ mode: 'native' }` | how tools reach the model: native schemas, Code Mode, or both | +| `tools` | `{ mode: 'native' }` | how tools reach the model: native schemas, PTC mode, or both | | `dshHome` | `$DSH_HOME` or `~/.dsh` | the harness home used for the bash environment and local skill folders | | `sessionTitle` | example limits | fallback title limits: 5 words, 40 fallback bytes, 80 accepted bytes | | `workspaceContext` | required | byte budget for loading workspace files into context, or `false` | diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 2dbc244cec..df84c48e82 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" | `includeRuntimeContext` | `true` | agent 历史是否包含动态运行时上下文快照 | | `persona` | `''` | 系统提示词中的部署 persona 文本 | | `toolOrder` | 字典序 | 模型看到工具的顺序 | -| `tools` | `{ mode: 'native' }` | 工具如何到达模型:native schema、Code Mode 或两者 | +| `tools` | `{ mode: 'native' }` | 工具如何到达模型:native schema、PTC mode 或两者 | | `dshHome` | `$DSH_HOME` 或 `~/.dsh` | bash 环境与本地 skill 目录使用的 harness 主目录 | | `sessionTitle` | 示例限制 | 后备标题限制:5 个词、40 个后备字节、80 个可接受字节 | | `workspaceContext` | 必填 | 加载工作区文件进上下文的字节预算,或 `false` | diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 1dd46a929f..5466123182 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/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/mcp/mcp-client/README.md -README.md: 5349d0fece112e735b69c0137341bacd85888e81 -README.zh.md: bb01b326da70f1214572f56546e7f39658b9ff0c +README.md: 929577e886b9f4a438739191191a1f0ee86c6343 +README.zh.md: ca07fd2b5d3547b09524a4097ce10bfb20251563 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 5349d0fece..929577e886 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -126,7 +126,7 @@ The supervisor listens for `notifications/tools/list_changed` and queues a re-sy ### Tool execution internals -A tool call sends an uncached `tools/call` request carrying the raw MCP name, the JSON arguments, the abort signal, and the configured timeout; the public name is never sent to the server and never parsed back. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving the complete MCP JSON blocks for programmatic and Code Mode callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. An MCP `isError` result throws before any image persistence, so the registry produces a failed tool result. Image batches are decoded and validated as a whole before any member is saved; any refusal projects every image as diagnostic text. +A tool call sends an uncached `tools/call` request carrying the raw MCP name, the JSON arguments, the abort signal, and the configured timeout; the public name is never sent to the server and never parsed back. Canonical success is `{ content: JsonValue[], structuredContent? }`, preserving the complete MCP JSON blocks for programmatic and PTC mode callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. An MCP `isError` result throws before any image persistence, so the registry produces a failed tool result. Image batches are decoded and validated as a whole before any member is saved; any refusal projects every image as diagnostic text. ### Environment scrubbing (stdio) @@ -171,7 +171,7 @@ The tool-definition prefix stays stable while the discovered set and schemas are #### What the model sees -The public tool name and JSON arguments remain in assistant history. The canonical value retains the complete MCP JSON blocks and optional structured content for programmatic and Code Mode callers; supported image blocks project beside text in their original order after exact route-capability proof. Refused images, audio, embedded resources, resource links, and unknown blocks remain visible as bounded text diagnostics, and MCP `isError` rejects the call before image persistence. +The public tool name and JSON arguments remain in assistant history. The canonical value retains the complete MCP JSON blocks and optional structured content for programmatic and PTC mode callers; supported image blocks project beside text in their original order after exact route-capability proof. Refused images, audio, embedded resources, resource links, and unknown blocks remain visible as bounded text diagnostics, and MCP `isError` rejects the call before image persistence. #### Token effect diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index bb01b326da..ca07fd2b5d 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -126,7 +126,7 @@ kind: "package-reference" ### 工具执行内部细节 -工具调用会发送一次未缓存的 `tools/call` 请求,携带原始 MCP 名称、JSON 参数、中止信号与配置的超时;公开名称绝不会发给服务器,也绝不会被解析还原。规范成功值是 `{ content: JsonValue[], structuredContent? }`,为程序化调用方与 Code Mode 调用方保留完整的 MCP JSON 块。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇回退为不受约束的 `JsonValue`。MCP 的 `isError` 结果会在任何图片持久化之前抛出,使注册表产生失败的工具结果。图片批次会先整体解码并校验,再保存任一成员;任何拒绝都会把每张图片投影为诊断文本。 +工具调用会发送一次未缓存的 `tools/call` 请求,携带原始 MCP 名称、JSON 参数、中止信号与配置的超时;公开名称绝不会发给服务器,也绝不会被解析还原。规范成功值是 `{ content: JsonValue[], structuredContent? }`,为程序化调用方与 PTC mode 调用方保留完整的 MCP JSON 块。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇回退为不受约束的 `JsonValue`。MCP 的 `isError` 结果会在任何图片持久化之前抛出,使注册表产生失败的工具结果。图片批次会先整体解码并校验,再保存任一成员;任何拒绝都会把每张图片投影为诊断文本。 ### 环境清洗(stdio) @@ -171,7 +171,7 @@ kind: "package-reference" #### 模型看到什么 -公开工具名称和 JSON 参数保留在 assistant 历史中。规范值始终为程序化调用方与 Code Mode 调用方保留完整的 MCP JSON 块与可选结构化内容;受支持的图片块在确切路由能力得到证明后,按原始顺序与文本一起投影。被拒绝的图片、音频、嵌入资源、资源链接与未知块继续以有界文本诊断可见;MCP `isError` 会在图片持久化之前拒绝调用。 +公开工具名称和 JSON 参数保留在 assistant 历史中。规范值始终为程序化调用方与 PTC mode 调用方保留完整的 MCP JSON 块与可选结构化内容;受支持的图片块在确切路由能力得到证明后,按原始顺序与文本一起投影。被拒绝的图片、音频、嵌入资源、资源链接与未知块继续以有界文本诊断可见;MCP `isError` 会在图片持久化之前拒绝调用。 #### Token 影响 diff --git a/packages/spill/README.i18n.yaml b/packages/spill/README.i18n.yaml index 3f2bba66d3..ed08cbef3b 100644 --- a/packages/spill/README.i18n.yaml +++ b/packages/spill/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/spill/README.md -README.md: 8afdf326f8cb1b280f8fca682cdf6ba852ae3d64 -README.zh.md: 5d3a89013e026b860dbf416662d97f062d55cc37 +README.md: 3eb56abf199eb2d38c0ad628b5b8a0fb222b1e70 +README.zh.md: 00a02536f9d33787755ce82b4c4355e11a0cceb6 diff --git a/packages/spill/README.md b/packages/spill/README.md index 8afdf326f8..3eb56abf19 100644 --- a/packages/spill/README.md +++ b/packages/spill/README.md @@ -39,7 +39,7 @@ Start with the subsystem reference for the shared vocabulary, then the design de - [Spill subsystem](../../docs/subsystems/spill.md) — the `SaveTextSpill`/`SpillRef` vocabulary, ownership, and backend relationships. - [Tool output spill decision](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) — the capability boundary between storage, retention, and tool-owned output handling. -- [Code dispatch-log spill decision](../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md) — why the durable copy of `run_code` sub-call results is bounded too. +- [Code dispatch-log spill decision](../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md) — why the durable copy of `run_code` sub-call results is bounded too. ## Dev Note diff --git a/packages/spill/README.zh.md b/packages/spill/README.zh.md index 5d3a89013e..00a02536f9 100644 --- a/packages/spill/README.zh.md +++ b/packages/spill/README.zh.md @@ -39,7 +39,7 @@ kind: "package-group" - [spill 子系统](../../docs/subsystems/spill.zh.md)——`SaveTextSpill`/`SpillRef` 词汇、归属与后端关系。 - [工具输出 spill 决策](../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)——存储、保留与工具自有输出处理之间的能力边界。 -- [代码 dispatch-log spill 决策](../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md)——为何 `run_code` 子调用结果的持久副本同样设界。 +- [代码 dispatch-log spill 决策](../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md)——为何 `run_code` 子调用结果的持久副本同样设界。 ## 开发备注 diff --git a/packages/spill/spill-policy/README.i18n.yaml b/packages/spill/spill-policy/README.i18n.yaml index 8eff951730..a269409815 100644 --- a/packages/spill/spill-policy/README.i18n.yaml +++ b/packages/spill/spill-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-policy/README.md -README.md: bf6de561230ea6c917e4b1bc74756e86f9d64a20 -README.zh.md: 6bfa02833375eb5611d61c20557cc53a8420e488 +README.md: ce7db195fe79623040f38da749ee1d45a46e317a +README.zh.md: f412f32e34c12a8ba3b28d272c932a463f2359d2 diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index bf6de56123..ce7db195fe 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -111,7 +111,7 @@ Read these pages when the package-level contract is not enough. - [dsh-spill-local](../spill-local/README.md) — the local backend that stores the spilled text. - [dsh-output-retention](../../util/output-retention/README.md) — the preview mechanics (`TextRetainer`) the policy composes. - [Tool output spill decision](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) — the capability boundary and design rationale. -- [Code dispatch-log spill decision](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.md) — why the durable log copy is bounded too. +- [Code dispatch-log spill decision](../../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md) — why the durable log copy is bounded too. ----- diff --git a/packages/spill/spill-policy/README.zh.md b/packages/spill/spill-policy/README.zh.md index 6bfa028333..f412f32e34 100644 --- a/packages/spill/spill-policy/README.zh.md +++ b/packages/spill/spill-policy/README.zh.md @@ -111,7 +111,7 @@ kind: "package-reference" - [dsh-spill-local](../spill-local/README.zh.md)——保存 spill 文本的本地后端。 - [dsh-output-retention](../../util/output-retention/README.zh.md)——策略组合的预览机制(`TextRetainer`)。 - [工具输出 spill 决策](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)——能力边界与设计依据。 -- [代码 dispatch-log spill 决策](../../../.agents/notes/implemented/feature/2026-07-26-code-dispatch-log-spill.zh.md)——为何持久日志副本同样设界。 +- [代码 dispatch-log spill 决策](../../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md)——为何持久日志副本同样设界。 ----- diff --git a/packages/subagent/subagent-fork-in-process/README.i18n.yaml b/packages/subagent/subagent-fork-in-process/README.i18n.yaml index 193bc83bf5..974678debb 100644 --- a/packages/subagent/subagent-fork-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-fork-in-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-fork-in-process/README.md -README.md: 141d0532ddd5cf5ef5702c92d6c923c30797b434 -README.zh.md: 28ca13c2e9926212df336235834d53fcf44d121f +README.md: 408d5efbd0426297eb02c44babf91d7fd2a271ff +README.zh.md: d1e0c2e034556d8dde5bff12bb09c4ec0ae0d678 diff --git a/packages/subagent/subagent-fork-in-process/README.md b/packages/subagent/subagent-fork-in-process/README.md index 141d0532dd..408d5efbd0 100644 --- a/packages/subagent/subagent-fork-in-process/README.md +++ b/packages/subagent/subagent-fork-in-process/README.md @@ -111,7 +111,7 @@ Read these pages when the package-level contract is not enough; they move from t #### What the model sees -The child receives the parent's balanced completed-turn prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and Code Mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited; an optional structured-output request adds a child-only contract; the parent's current in-flight turn is excluded. +The child receives the parent's balanced completed-turn prefix, then the new task content verbatim. A configured persona shadows prompt text in the child's fresh scope; a tool restriction filters its global wire schemas, executable lookup, and PTC mode SDK bindings but not standalone guidance. The parent's tool view and authority are not inherited; an optional structured-output request adds a child-only contract; the parent's current in-flight turn is excluded. #### Token effect diff --git a/packages/subagent/subagent-fork-in-process/README.zh.md b/packages/subagent/subagent-fork-in-process/README.zh.md index 28ca13c2e9..d1e0c2e034 100644 --- a/packages/subagent/subagent-fork-in-process/README.zh.md +++ b/packages/subagent/subagent-fork-in-process/README.zh.md @@ -111,7 +111,7 @@ base bundle 与 ACP/headless 示例在委派工具上把本提供方绑定为 `b #### 模型看到什么 -子 agent 先接收由父级已配平的已完成轮次构成的前缀,再逐字接收新的任务内容。配置的 persona 会在子 agent 的全新作用域中遮蔽提示词文本;工具限制会过滤其全局协议 schema、可执行工具查找与 Code Mode SDK 绑定,但不影响独立指导内容。父级的工具视图与权限不会被继承;可选的结构化输出请求会添加仅属于子 agent 的约定;父级当前进行中的轮次会被排除。 +子 agent 先接收由父级已配平的已完成轮次构成的前缀,再逐字接收新的任务内容。配置的 persona 会在子 agent 的全新作用域中遮蔽提示词文本;工具限制会过滤其全局协议 schema、可执行工具查找与 PTC mode SDK 绑定,但不影响独立指导内容。父级的工具视图与权限不会被继承;可选的结构化输出请求会添加仅属于子 agent 的约定;父级当前进行中的轮次会被排除。 #### Token 影响 diff --git a/packages/subagent/subagent-in-process-driver/README.i18n.yaml b/packages/subagent/subagent-in-process-driver/README.i18n.yaml index 0832860571..8009b8b8d8 100644 --- a/packages/subagent/subagent-in-process-driver/README.i18n.yaml +++ b/packages/subagent/subagent-in-process-driver/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-in-process-driver/README.md -README.md: 2bd76f04348518291974b07bd7786d603517e206 -README.zh.md: 97988f8e7c1cb2ce8dccac4cef2430f8bc6a8bed +README.md: 4f343862d336a0cf697ab3bf5cf73f86d1d235fe +README.zh.md: a77032317c592f4431b9bba4f5e808f4da8eb702 diff --git a/packages/subagent/subagent-in-process-driver/README.md b/packages/subagent/subagent-in-process-driver/README.md index 2bd76f0434..4f343862d3 100644 --- a/packages/subagent/subagent-in-process-driver/README.md +++ b/packages/subagent/subagent-in-process-driver/README.md @@ -65,7 +65,7 @@ The required request signal covers both startup and the live run. Before publica ### Structured output -`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope: a `structured_output` tool validates and stages the model's value against the requested schema; a trailing first-party order-9900 system-prompt section tells the child the tool call is the terminal answer; a `tools/result` observer commits a staged value only after the authoritative final tool result succeeds, including the enclosing `run_code` result for Code Mode sub-dispatch; and a monotonic tool guard blocks later calls after capture. A clean turn that never commits the required value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it. +`attachStructuredRuntime(childCtx, schema)` installs the whole contract in the child's scope: a `structured_output` tool validates and stages the model's value against the requested schema; a trailing first-party order-9900 system-prompt section tells the child the tool call is the terminal answer; a `tools/result` observer commits a staged value only after the authoritative final tool result succeeds, including the enclosing `run_code` result for PTC mode sub-dispatch; and a monotonic tool guard blocks later calls after capture. A clean turn that never commits the required value reports `error`; the driver does not re-prompt. All registrations ride the child fiber and disappear with it. ### Source map @@ -98,7 +98,7 @@ Read these pages when the package-level contract is not enough; they move from t #### What the model sees -The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and Code Mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed. +The shared driver sends the task verbatim as the child's user message and, when requested, shadows the persona and restricts global tool schemas, lookup, execution, and PTC mode SDK bindings in the unpublished child's fresh scope; parent restrictions are not inherited, and standalone tool-guidance sections remain. Spawn supplies no history; fork supplies its balanced seed. #### Token effect diff --git a/packages/subagent/subagent-in-process-driver/README.zh.md b/packages/subagent/subagent-in-process-driver/README.zh.md index 97988f8e7c..a77032317c 100644 --- a/packages/subagent/subagent-in-process-driver/README.zh.md +++ b/packages/subagent/subagent-in-process-driver/README.zh.md @@ -65,7 +65,7 @@ kind: "package-library" ### 结构化输出 -`attachStructuredRuntime(childCtx, schema)` 会在子 agent 作用域中安装完整约定:`structured_output` 工具按请求的 schema 校验并暂存模型值;位于末尾、first-party 顺序为 9900 的系统提示词段告诉子 agent 该工具调用就是终态答案;`tools/result` 观察器只在该次执行的权威最终工具结果成功后提交暂存值,包括 Code Mode 子分派外层的 `run_code` 结果;单调工具防护会在捕获后阻止后续调用。正常结束却始终未提交必需值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。 +`attachStructuredRuntime(childCtx, schema)` 会在子 agent 作用域中安装完整约定:`structured_output` 工具按请求的 schema 校验并暂存模型值;位于末尾、first-party 顺序为 9900 的系统提示词段告诉子 agent 该工具调用就是终态答案;`tools/result` 观察器只在该次执行的权威最终工具结果成功后提交暂存值,包括 PTC mode 子分派外层的 `run_code` 结果;单调工具防护会在捕获后阻止后续调用。正常结束却始终未提交必需值的轮次会报告 `error`;驱动器不会重新提示。所有注册都附着于子 agent fiber,并随其一同消失。 ### 源码地图 @@ -98,7 +98,7 @@ kind: "package-library" #### 模型看到什么 -共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行与 Code Mode SDK 绑定。父级限制不会被继承,独立的工具指导段仍会保留。spawn 不提供历史;fork 提供其已配平的初始内容。 +共享驱动器把任务逐字作为子 agent 的用户消息发送;若有请求,还会在未发布子 agent 的全新作用域中遮蔽 persona,并限制全局工具 schema、查找、执行与 PTC mode SDK 绑定。父级限制不会被继承,独立的工具指导段仍会保留。spawn 不提供历史;fork 提供其已配平的初始内容。 #### Token 影响 diff --git a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml index 2e3543a39b..c701d9180f 100644 --- a/packages/subagent/subagent-spawn-in-process/README.i18n.yaml +++ b/packages/subagent/subagent-spawn-in-process/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/subagent/subagent-spawn-in-process/README.md -README.md: 44cc0a81628192af7973c89feedf6b33fbfd3914 -README.zh.md: f8fd055aa631aba06598c24ef8d13e836f242eba +README.md: 5eb2db403f3c572a6cd708a2bf32c7e9d54aafca +README.zh.md: 028815190ed7880c310661a3b8b7cd63f487afef diff --git a/packages/subagent/subagent-spawn-in-process/README.md b/packages/subagent/subagent-spawn-in-process/README.md index 44cc0a8162..5eb2db403f 100644 --- a/packages/subagent/subagent-spawn-in-process/README.md +++ b/packages/subagent/subagent-spawn-in-process/README.md @@ -106,7 +106,7 @@ Read these pages when the package-level contract is not enough; they move from t #### What the model sees -The fresh child receives the task content verbatim as its only user message in a new empty conversation, with the parent provider, model, reasoning effort, output-token limit, and working directory by default. A configured persona shadows global prompt text in the child's scope; a tool filter removes named global tools from its schemas, executable lookup, and Code Mode SDK bindings while leaving independently registered guidance. No parent conversation message is included; the filter is composition, not an inherited authority grant. +The fresh child receives the task content verbatim as its only user message in a new empty conversation, with the parent provider, model, reasoning effort, output-token limit, and working directory by default. A configured persona shadows global prompt text in the child's scope; a tool filter removes named global tools from its schemas, executable lookup, and PTC mode SDK bindings while leaving independently registered guidance. No parent conversation message is included; the filter is composition, not an inherited authority grant. #### Token effect diff --git a/packages/subagent/subagent-spawn-in-process/README.zh.md b/packages/subagent/subagent-spawn-in-process/README.zh.md index f8fd055aa6..028815190e 100644 --- a/packages/subagent/subagent-spawn-in-process/README.zh.md +++ b/packages/subagent/subagent-spawn-in-process/README.zh.md @@ -106,7 +106,7 @@ kind: "package-reference" #### 模型看到什么 -全新子 agent 逐字接收任务内容,作为新空对话中的唯一用户消息,默认使用父级提供方、模型、推理等级、输出 token 上限与工作目录。配置的 persona 会在子 agent 作用域中遮蔽全局提示词文本;工具过滤器会从其 schema、可执行工具查找与 Code Mode SDK 绑定中移除指定的全局工具,但保留独立注册的指导内容。不包含任何父级对话消息;过滤属于组合,而非继承的权限授予。 +全新子 agent 逐字接收任务内容,作为新空对话中的唯一用户消息,默认使用父级提供方、模型、推理等级、输出 token 上限与工作目录。配置的 persona 会在子 agent 作用域中遮蔽全局提示词文本;工具过滤器会从其 schema、可执行工具查找与 PTC mode SDK 绑定中移除指定的全局工具,但保留独立注册的指导内容。不包含任何父级对话消息;过滤属于组合,而非继承的权限授予。 #### Token 影响 diff --git a/packages/workflow/tool-workflow/README.i18n.yaml b/packages/workflow/tool-workflow/README.i18n.yaml index 0cf63fd328..e1ecdad4e1 100644 --- a/packages/workflow/tool-workflow/README.i18n.yaml +++ b/packages/workflow/tool-workflow/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/workflow/tool-workflow/README.md -README.md: 697b9e95dd832cf3036842a1a1f1f80a17fcc5b6 -README.zh.md: 51d7a3cd55a23d5c6c0abafb4eef8ada7f2db89b +README.md: 1677d8ae67affa9c3ca0ace57ae0e1103bee7201 +README.zh.md: 479db077b481076e56179bcb4b9551b46fb398bc diff --git a/packages/workflow/tool-workflow/README.md b/packages/workflow/tool-workflow/README.md index 697b9e95dd..1677d8ae67 100644 --- a/packages/workflow/tool-workflow/README.md +++ b/packages/workflow/tool-workflow/README.md @@ -159,7 +159,7 @@ These limits define what the tool does not yet support. They are current constra - **The parent turn blocks until the whole workflow settles** — there is no background start/poll API, and cancellation discards partial output as an error. - **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays and scalars in a field; the canonical workflow result stays complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle. - **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments. -- **Durable records are top-level and observational** — nested Code Mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution. +- **Durable records are top-level and observational** — nested PTC mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution. ### Dev Note diff --git a/packages/workflow/tool-workflow/README.zh.md b/packages/workflow/tool-workflow/README.zh.md index 51d7a3cd55..479db077b4 100644 --- a/packages/workflow/tool-workflow/README.zh.md +++ b/packages/workflow/tool-workflow/README.zh.md @@ -159,7 +159,7 @@ Use the tool ONLY when the user explicitly asks for a workflow or for - **父级轮次会阻塞到整个工作流结算**——没有后台启动/轮询接口,取消会丢弃局部输出并返回错误。 - **`args` 必须是对象,Native 结果文本有界**——调用方把顶层数组/标量包装到字段中;规范工作流结果保持完整,超过 `maxResultChars` 的 JSON 会在面向模型的投影中截断,而不是存储在检索句柄背后。 - **每次工具注册的工作流策略固定**——提供方选择、上限与工具名称属于部署配置,不是模型调用参数。 -- **持久记录只覆盖顶层且只供观察**——嵌套 Code Mode dispatch 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。 +- **持久记录只覆盖顶层且只供观察**——嵌套 PTC mode dispatch 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。 ### 开发备注 From 215e90dfb26a81d7a0a2a77d161de28bdb1fe816 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 26 Aug 2026 01:48:48 +0800 Subject: [PATCH 121/130] fix: rename the remaining code mode-value prose found in review The subagent review found stale code mode-value prose the mechanical pass missed: tools and agent-tool-presentation READMEs (en/zh), the CLI reference page (DSH_TOOLS_MODE and the preset roster), the zh tool catalog, the execute JSDoc and collapse comments in dsh-tools, the codeModeHarness helper in agent-loop tests, and stale code-mode titles in ptc.spec. --- apps/cli/reference/README.md | 4 ++-- apps/cli/reference/README.zh.md | 4 ++-- docs/tool-catalog.i18n.yaml | 2 +- docs/tool-catalog.zh.md | 2 +- packages/core/agent-loop/tests/tool-calls.spec.ts | 8 ++++---- .../core/agent-tool-presentation/README.i18n.yaml | 4 ++-- packages/core/agent-tool-presentation/README.md | 8 ++++---- packages/core/agent-tool-presentation/README.zh.md | 6 +++--- packages/core/tools/README.md | 10 +++++----- packages/core/tools/README.zh.md | 8 ++++---- packages/core/tools/src/index.ts | 12 ++++++------ packages/core/tools/tests/ptc.spec.ts | 10 +++++----- 12 files changed, 39 insertions(+), 39 deletions(-) diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 03ec7c3a14..f5cbe1659e 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -85,11 +85,11 @@ The base-backed modes treat the invoking directory as the default workspace root New sessions in base-backed profiles default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads and network access are not confined, while process visibility depends on the selected sandbox backend — bwrap runs commands in a private PID namespace that hides host processes, and Landlock and Seatbelt leave host process visibility unchanged. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. The standalone `sdk-minimal` tree instead pins `danger-full-access` and mounts no approval or permission-settings service. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. +`DSH_TOOLS_MODE` selects `native`, `ptc`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `code`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, the public-only HTTP fetch provider, and feedback-gated session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. The Web app's `cordis`, `ptc`, and `standard` agent presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation; the provider still rejects non-public destinations before connecting. Session telemetry defaults to feedback-gated sharing: nothing is uploaded until the user records `/feedback`, and each recorded feedback uploads the session records not yet shared, through that event; a resumed session shares only its current lifecycle. `DSH_TELEMETRY_MODE=FULL` instead streams every projected session event as OTLP/HTTP logs, `DSH_TELEMETRY_MODE=DISABLED` keeps everything local, and any non-empty `DSH_TELEMETRY_DISABLED` remains an authoritative hard opt-out. `DSH_TELEMETRY_OTLP_URL` selects another collector. The shipped base has no telemetry redaction rule, so released exports can contain message text, tool arguments and results, and workspace paths; the [feedback-gated-default Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.md) owns that deployment decision. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index d865583265..cf040f0852 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -85,11 +85,11 @@ dsh web --help 基于 base 的 profile 中,新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取和网络访问不受限制,进程可见性则取决于所选沙箱后端——bwrap 在私有 PID 命名空间中运行命令并隐藏宿主进程,Landlock 与 Seatbelt 保持宿主进程可见性不变。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。独立的 `sdk-minimal` 配置树则固定为 `danger-full-access`,且不挂载 approval 或权限 settings 服务。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`ptc` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`code` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、仅限公网的 HTTP fetch 提供方,以及按反馈门控的会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。Web app 的 `cordis`、`ptc` 与 `standard` agent preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认;提供方仍会在连接前拒绝非公开目的地址。 会话遥测默认按反馈门控共享:在用户记录 `/feedback` 之前不上传任何数据,每条已记录的反馈通过该事件上传尚未共享的会话记录;恢复的会话只共享当前生命周期。`DSH_TELEMETRY_MODE=FULL` 改为将每条已投影会话事件作为 OTLP/HTTP 日志流式发送,`DSH_TELEMETRY_MODE=DISABLED` 让全部数据留在本地,任何非空的 `DSH_TELEMETRY_DISABLED` 仍是具有最终效力的遥测强制关闭开关。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。随附基础配置没有遥测脱敏规则,因此释放的导出可能包含消息文本、工具参数和结果,以及 workspace 路径;相关部署决策见[反馈门控默认值 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-feedback-gated-telemetry-default.zh.md)。 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 17f6aae194..5a00cc6efa 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md tool-catalog.md: 91ff093e79cc05a2c08b5aa1130378440cd963f3 -tool-catalog.zh.md: 5aa915489c415b5d58fa1d5181431cf42ef3c53f +tool-catalog.zh.md: 2cded94f48a01dca34f9433df48232aa9d726d58 diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 5aa915489c..2cded94f48 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -150,7 +150,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类 来源:[`packages/core/tools/src/ptc.ts`](../packages/core/tools/src/ptc.ts) -在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `code` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 +在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 354ed920f8..fe02ff7cf3 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -700,7 +700,7 @@ describe('PTC mode native-tool denial through the agent loop', () => { } } - async function codeModeHarness(adapter: MockAdapter) { + async function ptcModeHarness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) @@ -714,7 +714,7 @@ describe('PTC mode native-tool denial through the agent loop', () => { return ctx } - it('denies a model-direct native-tool call under code mode: tool body never runs and session records UNKNOWN_TOOL', async () => { + it('denies a model-direct native-tool call under PTC mode: tool body never runs and session records UNKNOWN_TOOL', async () => { let toolInvoked = false const tool = defineContentToolFixture({ name: 'write', @@ -729,7 +729,7 @@ describe('PTC mode native-tool denial through the agent loop', () => { }, }) - // Scripted model emits a native tool call under code mode — the wire + // Scripted model emits a native tool call under PTC mode — the wire // never advertised it, but a non-compliant provider may still emit one. const adapter = new MockAdapter([ [ @@ -738,7 +738,7 @@ describe('PTC mode native-tool denial through the agent loop', () => { ], ]) - const ctx = await codeModeHarness(adapter) + const ctx = await ptcModeHarness(adapter) ctx.tools.register(tool) const agent = ctx.agentLoop.create(SessionId('code-native'), { provider: 'mock', model: 'mock' }) diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index 7a16de9ab8..26e0117028 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/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-presentation/README.md -README.md: 425a652f29dfbc99978c3f29839a099f1d9a1c52 -README.zh.md: 859045c12ca532cea2f5eb8a463c4e58662d51c2 +README.md: c57938f2b32bead9558b283112c09e8a46b6e866 +README.zh.md: bbe7801fba2e3f284be0146c1375f73a5dc00009 diff --git a/packages/core/agent-tool-presentation/README.md b/packages/core/agent-tool-presentation/README.md index 425a652f29..c57938f2b3 100644 --- a/packages/core/agent-tool-presentation/README.md +++ b/packages/core/agent-tool-presentation/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -An [agent preset](../../preset/agent-presets/README.md) carries `dsh-agent-tool-presentation` to choose which form of its tools the model sees: `native` (every visible schema), `code` (only `run_code` plus a generated SDK), or `both`. The tool registry itself stays on the host plane — this row only declares the presentation for the mounting agent, so a PTC mode session runs beside native ones in one process, each seeing its own catalog. A PTC mode waits for a code runtime before mounting, so a preset selecting PTC mode against a deployment without one fails at mount instead of at the first prompt. The `mode` field is required: a preset without this row already gets the deployment default. Choose it when an agent preset needs to fix the tool form its agents' models see. +An [agent preset](../../preset/agent-presets/README.md) carries `dsh-agent-tool-presentation` to choose which form of its tools the model sees: `native` (every visible schema), `ptc` (only `run_code` plus a generated SDK), or `both`. The tool registry itself stays on the host plane — this row only declares the presentation for the mounting agent, so a PTC mode session runs beside native ones in one process, each seeing its own catalog. A PTC mode waits for a code runtime before mounting, so a preset selecting PTC mode against a deployment without one fails at mount instead of at the first prompt. The `mode` field is required: a preset without this row already gets the deployment default. Choose it when an agent preset needs to fix the tool form its agents' models see. ## Table of Contents @@ -25,7 +25,7 @@ An [agent preset](../../preset/agent-presets/README.md) carries `dsh-agent-tool- ## Use this package -Add this row to an agent preset to fix how every agent joined to that preset sees its tools. `native` presents each visible tool schema as a function definition; `code` presents only the `run_code` transport plus a generated SDK and the rule that only `run_code` may be called directly; `both` presents both forms. Agents that declare nothing get the deployment-wide `mode` on the [`dsh-tools`](../tools/README.md) row. +Add this row to an agent preset to fix how every agent joined to that preset sees its tools. `native` presents each visible tool schema as a function definition; `ptc` presents only the `run_code` transport plus a generated SDK and the rule that only `run_code` may be called directly; `both` presents both forms. Agents that declare nothing get the deployment-wide `mode` on the [`dsh-tools`](../tools/README.md) row. ### Add the row to a preset @@ -37,13 +37,13 @@ Add this row to an agent preset to fix how every agent joined to that preset see | Field | Default | Meaning | |---|---|---| -| `mode` | required | `native` — every schema; `code` — `run_code` plus generated SDK; `both` — both forms | +| `mode` | required | `native` — every schema; `ptc` — `run_code` plus generated SDK; `both` — both forms | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-agent-tool-presentation) is the exhaustive source for every accepted field. `mode` is required rather than defaulted because a preset without this row inherits the deployment default. ### What PTC mode requires -Selecting `code` or `both` needs a composed code runtime (`ctx.codeRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tools`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. +Selecting `ptc` or `both` needs a composed code runtime (`ctx.codeRuntime`) whose language has a registered SDK renderer — the TypeScript runtime ships via [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.md), and both the TypeScript and Python SDK renderers are built into `dsh-tools`. A preset that selects a PTC mode against a deployment composing no such runtime refuses to mount, naming this row, so the failure lands where the operator can act instead of at the session's first request. ### One presentation per agent diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index 859045c12c..bbe7801fba 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -[agent preset](../../preset/agent-presets/README.zh.md) 携带 `dsh-agent-tool-presentation`,用来声明「模型看到其工具的哪一种形态」:`native`(每个可见 schema)、`code`(只有 `run_code` 加一份生成的 SDK)或 `both`。工具注册表本身仍在宿主平面——这一行只声明挂载 agent 的呈现方式,因此一个 PTC mode 会话可以与多个 native 会话同进程并存,各自看到各自的目录。code 类模式在挂载前会等待代码运行时,因此针对未组装运行时的部署选择 PTC mode 的 preset 会在挂载时失败,而不是在第一次请求时失败。`mode` 字段是必填的:不带这一行的 preset 本来就会拿到部署默认值。当 agent preset 需要固定其 agent 的模型所看到的工具形态时,请选择本包。 +[agent preset](../../preset/agent-presets/README.zh.md) 携带 `dsh-agent-tool-presentation`,用来声明「模型看到其工具的哪一种形态」:`native`(每个可见 schema)、`ptc`(只有 `run_code` 加一份生成的 SDK)或 `both`。工具注册表本身仍在宿主平面——这一行只声明挂载 agent 的呈现方式,因此一个 PTC mode 会话可以与多个 native 会话同进程并存,各自看到各自的目录。PTC 模式在挂载前会等待代码运行时,因此针对未组装运行时的部署选择 PTC mode 的 preset 会在挂载时失败,而不是在第一次请求时失败。`mode` 字段是必填的:不带这一行的 preset 本来就会拿到部署默认值。当 agent preset 需要固定其 agent 的模型所看到的工具形态时,请选择本包。 ## 目录 @@ -43,7 +43,7 @@ kind: "package-reference" ### PTC 模式需要什么 -选择 `code` 或 `both` 需要已组合的代码运行时(`ctx.codeRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tools` 中。针对未组装此类运行时的部署选择 code 类模式的 preset 会拒绝挂载并点名这一行,使失败落在操作者可以行动的地方,而不是落在会话的第一次请求上。 +选择 `ptc` 或 `both` 需要已组合的代码运行时(`ctx.codeRuntime`),且其语言有已注册的 SDK 渲染器——TypeScript 运行时经 [`dsh-code-runtime-worker-thread`](../../code-runtime/code-runtime-worker-thread/README.zh.md) 交付,TypeScript 与 Python 的 SDK 渲染器都内置在 `dsh-tools` 中。针对未组装此类运行时的部署选择 PTC 模式的 preset 会拒绝挂载并点名这一行,使失败落在操作者可以行动的地方,而不是落在会话的第一次请求上。 ### 每个 agent 只声明一次呈现方式 @@ -72,7 +72,7 @@ kind: "package-reference" ### 行为说明 -`native` 立即生效。code 类模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 +`native` 立即生效。PTC 模式则等待 `ctx.codeRuntime`——这是一个宿主平面服务:针对未组装运行时的部署选择 PTC mode 的 preset 会让这一行停在 pending,`dsh-agent-presets` 会指名此 id 拒绝挂载。`presentAs` 本身就是 effect,因此该声明随这一行撤销,无需第二个包装层拥有它。 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 0ab3633c91..f586b926ec 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -61,7 +61,7 @@ The unified schema DSL supports `string`, `number`, `integer`, `boolean`, `null` ### Configure the presentation mode -The `mode` config decides what the model sees: `native` (every visible schema), `code` (only `run_code` plus a generated SDK), or `both`. +The `mode` config decides what the model sees: `native` (every visible schema), `ptc` (only `run_code` plus a generated SDK), or `both`. ```yaml - name: '@deepseek-ai/dsh-tools' @@ -71,7 +71,7 @@ The `mode` config decides what the model sees: `native` (every visible schema), | Field | Default | Meaning | |---|---|---| -| `mode` | `native` | How visible tools are presented to the model: `native`, `code`, or `both` | +| `mode` | `native` | How visible tools are presented to the model: `native`, `ptc`, or `both` | | `maxParallelSubCalls` | `10` | Concurrency cap for a `run_code` program's overlapping sub-calls; `1` restores strictly serial dispatch | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-tools) is the exhaustive source for every accepted field. Non-native modes require a composed `ctx.codeRuntime` whose language has a registered SDK renderer; an agent preset selects its own presentation with [`dsh-agent-tool-presentation`](../agent-tool-presentation/README.md), and one agent can shadow the default with `presentAs(mode)`. @@ -122,7 +122,7 @@ Each typed invocation materializes and freezes parsed arguments, assigns an opaq ### PTC mode -Under `code` or `both`, the registry exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding call re-enters the complete tool pipeline with logged correlation to the outer call, scheduled through a per-run pool that reuses the native concurrency contract. Under `code` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. The [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md) owns the collapse contract. +Under `ptc` or `both`, the registry exposes the reserved `run_code` transport plus a deterministic SDK generated in the loaded runtime's language. Each SDK binding call re-enters the complete tool pipeline with logged correlation to the outer call, scheduled through a per-run pool that reuses the native concurrency contract. Under `ptc` alone, a model-direct call naming any other visible tool resolves to `UNKNOWN_TOOL` before policy — the announced surface and the callable surface stay the same. Intermediate binding values are execution-local; only the outer `run_code` result has a hard size cap. The [executor-collapse note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.md) owns the collapse contract. ### Extension points @@ -168,7 +168,7 @@ Prefix-stable while visible definitions and their order are unchanged. Registrat #### What the model sees -PTC 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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `code` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. +PTC 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 instructions identify generated declarations as program-only bindings. When the current `bash` parameter schema accepts the example arguments, they also show a complete `run_code` call around `tools.bash(...)`. The `tools:sdk` section uses first-party order 5000. `both` exposes normal schemas and this PTC mode API; under `ptc` the prompt also carries the `tools:ptc-only` rule earlier in the first-party order, so the model reads which tools it may call before it reads what each one is for. ##### TypeScript PTC mode SDK instructions with bash @@ -222,7 +222,7 @@ These limits define when the registry needs special care. They are current packa - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). - **Caller-defined subagent and workflow structured outputs remain object-rooted** — this is a consumer-level guard; the shared schema vocabulary and tool outputs support every JSON root. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-tool-call-timeout-policy` wrapper. -- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: ptc`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is code-only. +- **PTC mode's SDK language follows the one loaded runtime, and a presentation is per agent rather than per tool** — `mode: ptc`/`both` rejects prompt assembly unless `ctx.codeRuntime.language` has a registered SDK renderer; within one agent no tool can be native-only while another is ptc-only. - **PTC mode intermediate values are execution-local and unbounded by bytes** — they cannot be reconstructed from session replay and may exhaust process or worker memory; only the outer `run_code` output has the worker's configurable hard cap. - **`run_code` state is fresh per run** — a persistent REPL-style kernel is rejected for the MVP, because cross-call state would be invisible to the log. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 48b5a123c3..15282a7dff 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -71,7 +71,7 @@ ctx.tools.register(defineTool({ | 字段 | 默认值 | 含义 | |---|---|---| -| `mode` | `native` | 可见工具向模型呈现的方式:`native`、`code` 或 `both` | +| `mode` | `native` | 可见工具向模型呈现的方式:`native`、`ptc` 或 `both` | | `maxParallelSubCalls` | `10` | `run_code` 程序重叠子调用的并发上限;`1` 恢复严格串行分发 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-tools)是每个受支持字段的穷尽式真源。非原生模式要求已组合的 `ctx.codeRuntime` 且其语言有已注册的 SDK 渲染器;agent preset 通过 [`dsh-agent-tool-presentation`](../agent-tool-presentation/README.zh.md) 自行选择呈现方式,单个 agent 可用 `presentAs(mode)` 遮蔽默认值。 @@ -122,7 +122,7 @@ ctx.tools.register(defineTool({ ### PTC mode -在 `code` 或 `both` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定调用都会在日志中与外层调用关联,重新进入完整工具流水线,并通过复用原生并发约定的每次运行独有池调度。在纯 `code` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md) 拥有该收束约定。 +在 `ptc` 或 `both` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定调用都会在日志中与外层调用关联,重新进入完整工具流水线,并通过复用原生并发约定的每次运行独有池调度。在纯 `code` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md) 拥有该收束约定。 ### 扩展点 @@ -168,7 +168,7 @@ ctx.tools.register(defineTool({ #### 模型看到什么 -PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `code` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 +PTC mode 会公开生成的 [`run_code` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tools)、下方 SDK 说明,以及按所加载运行时语言生成的精确 SDK 块。TypeScript 说明会把生成声明明确标为只能在程序内使用的绑定。当当前 `bash` 参数 schema 接受示例参数时,说明还会给出以 `run_code` 包住 `tools.bash(...)` 的完整调用。`tools:sdk` 段使用 first-party 顺序 5000。`both` 会同时公开普通 schema 与此 PTC mode API;在 `ptc` 下,提示词还会带上处于更早 first-party 顺序的 `tools:ptc-only` 规则,让模型先读到「可以调用哪些工具」再读「每个工具做什么」。 ##### 带 bash 的 TypeScript PTC mode SDK 说明 @@ -222,7 +222,7 @@ Program-only SDK bindings: - **`tools/pre-execute` 有意不允许改写 `exec.arguments`**:否则日志记录与呈现的参数会与实际运行内容失去同步;改写设计记录在[拟议的 Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)中。 - **调用方定义的 subagent 与工作流结构化输出仍要求对象根**:这是消费方层面的守卫;共享 schema 词汇与工具输出支持任意 JSON 根。 - **定义中的 `timeoutMs` 仅作声明之用**:注册表绝不会强制执行截止时间;要强制执行,必须使用 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。 -- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: ptc`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 Code。 +- **PTC mode 的 SDK 语言由当前加载的运行时决定,且呈现方式按 agent 而非按工具**:`mode: ptc`/`both` 会拒绝组装提示词,除非 `ctx.codeRuntime.language` 有已注册的 SDK 渲染器;同一个 agent 内不能让一个工具仅使用 Native,而另一个仅使用 PTC。 - **PTC mode 中间值只存在于执行局部,且没有字节上限**:它们无法从会话回放重建,并可能耗尽进程或 worker 内存;只有外层 `run_code` 输出受 worker 可配置的硬上限约束。 - **每次运行都会获得全新的 `run_code` 状态**:MVP 不采用持久 REPL 风格内核,因为跨调用状态不会出现在日志中。 diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index f0b855cb8e..9513fe0a3c 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -819,7 +819,7 @@ export class ToolRuntime extends Service { /** * Reserved presentation transport, kept outside the filterable registration * layers. Built on first need rather than at construction: which agents run - * a code mode is no longer known when the service is constructed, and the + * a PTC mode is no longer known when the service is constructed, and the * transport is stateless beyond its closures over `this`. */ private ptcTransport: ToolDefinition | undefined @@ -838,8 +838,8 @@ export class ToolRuntime extends Service { } /** - * The prompt statement of the `code` executor collapse, registered wherever - * {@link sdkSection} is and rendering empty outside an effective `code`. + * The prompt statement of the `ptc` executor collapse, registered wherever + * {@link sdkSection} is and rendering empty outside an effective `ptc`. * * 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. @@ -1207,7 +1207,7 @@ export class ToolRuntime extends Service { /** * 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` + * (`get`) is presentation-agnostic; here a MODEL-DIRECT call under `ptc` * 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` @@ -1311,7 +1311,7 @@ export class ToolRuntime extends Service { * 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` + * Resolved through {@link modeFor}, NOT `defaultMode`: an agent given `ptc` * by an agent preset under a native deployment is the composition * `dsh-agent-tool-presentation` exists for, and reading the deployment default would * leave exactly that agent uncollapsed — announcing one surface while @@ -1370,7 +1370,7 @@ export class ToolRuntime extends Service { const parent = exec.parent const signal = exec.signal // Distinguish a mode-collapsed call (visible in the scope, denied only by - // the `code` collapse) from a genuinely unknown tool. A collapsed call is + // the `ptc` 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 diff --git a/packages/core/tools/tests/ptc.spec.ts b/packages/core/tools/tests/ptc.spec.ts index 4031d22cdc..6a29701aa4 100644 --- a/packages/core/tools/tests/ptc.spec.ts +++ b/packages/core/tools/tests/ptc.spec.ts @@ -391,7 +391,7 @@ describe('mode-aware wire contribution', () => { it("assembles under a python runtime in mode 'both' as well, SDK and schema together", async () => { // `both` reaches the same wireSchemas/requireCodeRuntime/SDK-section code - // as `code`, so this pins the mode-by-language matrix rather than a + // as `ptc`, so this pins the mode-by-language matrix rather than a // separate path — including that the `wireSchemas` projection behind // `assembly.tools` picks the Python flavor under `both` instead of hitting // the flavor-table guard. @@ -401,7 +401,7 @@ describe('mode-aware wire contribution', () => { expect(assembly.sections.find(section => section.name === 'tools:sdk')?.text).toContain('class Tools(Protocol):') const runCodeSchema = assembly.tools.find(tool => tool.name === RUN_CODE_NAME) expect(runCodeSchema?.description).toContain('Execute a Python program') - // `both` keeps the native tools alongside run_code; `code` does not. + // `both` keeps the native tools alongside run_code; `ptc` does not. expect(assembly.tools.map(tool => tool.name)).toContain('echo') }) @@ -453,7 +453,7 @@ describe('mode-aware wire contribution', () => { it('degrades the run_code flavor to TypeScript when no runtime is mounted', async () => { // Any reader of the definition without a mounted runtime uses this fallback; the // shipped one is the tool-catalog generator, which boots the registry under - // `mode: code` and reads run_code's schema WITHOUT a runtime. peekRuntime + // `mode: ptc` and reads run_code's schema WITHOUT a runtime. peekRuntime // returns undefined there, so the flavor getter degrades to the TS default // rather than throwing. None of those readers feeds a model: assembly goes // through wireSchemas, which requires a runtime first. @@ -1657,7 +1657,7 @@ describe('the run_code dispatch bridge', () => { .toThrow('maxParallelSubCalls must be a positive integer') }) - it('direct construction in code mode defaults the parallel sub-call cap', async () => { + it('direct construction in PTC mode defaults the parallel sub-call cap', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) const registry = new ToolRuntime(ctx, { mode: 'ptc' }) @@ -1672,7 +1672,7 @@ 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 () => { + it('denies a model-direct native-tool call under PTC mode as UNKNOWN_TOOL', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, {}) const registry = new ToolRuntime(ctx, { mode: 'ptc' }) From 70af4edf3e12b27b17218234979975e322257a12 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:21:04 +0800 Subject: [PATCH 122/130] fix: point the rename note at the fail-closed session-event vocabulary note Master replaced the session-log-version-mechanism note with the fail-closed-session-event-vocabulary note; update the rename note's links (en/zh) so cross-links resolve. --- ...26-08-25-rename-code-mode-to-ptc.i18n.yaml | 4 +- .../2026-08-25-rename-code-mode-to-ptc.md | 2 +- .../2026-08-25-rename-code-mode-to-ptc.zh.md | 2 +- docs/subsystems/tools.i18n.yaml | 4 +- docs/subsystems/tools.md | 70 +++++++++---------- docs/subsystems/tools.zh.md | 70 +++++++++---------- packages/core/tools/src/types.ts | 5 ++ .../extensions/tool-cordis/src/api-catalog.ts | 28 ++++---- .../cordis-inspect-jsdoc/session.jsonl | 2 +- 9 files changed, 96 insertions(+), 91 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml index 4ffac0a8b3..b7b59dc1c4 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.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-25-rename-code-mode-to-ptc.md -2026-08-25-rename-code-mode-to-ptc.md: 5637a17b8fcda3eee831e25cbad5662d1a93320b -2026-08-25-rename-code-mode-to-ptc.zh.md: 39cec2dfba133c388f80095f1f40934fb4f0325c +2026-08-25-rename-code-mode-to-ptc.md: 9f53b9b5d8c3581d5c2dfe0174ad1c279cf47ba3 +2026-08-25-rename-code-mode-to-ptc.zh.md: 56a9e5ec3ca660fd36d21f9c4dbcb1d5cbd5fbf9 diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md index 5637a17b8f..9f53b9b5d8 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.md @@ -34,4 +34,4 @@ Kept unchanged: `run_code` and its `code` parameter (they name the program paylo ## Consequences -Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are the [session-log-version note](2026-08-10-session-log-version-mechanism.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md). +Configs with `mode: code` and preset ids `code` are unsupported on this build. The session-persistent vocabulary still says `tool/code-dispatch*`, `tools-code-mode`, and `:code:`, so existing session logs load unchanged and no `SESSION_FORMAT_VERSION` bump is needed yet. The stacked persistence PR renames that vocabulary and is blocked until the v0→v1 migration lands with it (the version mechanics are the [session-event-vocabulary note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.md)). Keyless snapshot refreshes carry this PR's vocabulary; the persistence PR refreshes the dispatch-bearing fixtures. The shipped decision this note renames is [the PTC foundation note](../feature/2026-06-15-ptc.md). diff --git a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md index 39cec2dfba..56a9e5ec3c 100644 --- a/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-25-rename-code-mode-to-ptc.zh.md @@ -34,4 +34,4 @@ Status: implemented ## 后果 -配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [session log 版本机制 Note](2026-08-10-session-log-version-mechanism.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。 +配置中写 `mode: code`、预设 id 为 `code`,在本构建上不再受支持。会话持久词汇仍为 `tool/code-dispatch*`、`tools-code-mode` 与 `:code:`,因此既有会话日志照常读取,无需 `SESSION_FORMAT_VERSION` 提升。堆叠的持久化 PR 负责重命名该词汇,并被阻塞到 v0→v1 迁移与其一同落地(版本机制见 [session event 词汇 Note](../simplification/2026-08-25-fail-closed-session-event-vocabulary.zh.md))。无密钥的 snapshot refresh 携带本 PR 的词汇;持久化 PR 刷新包含分发的夹具。本 Note 所更名的已发布决策是 [PTC 基础 Note](../feature/2026-06-15-ptc.zh.md)。 diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index 3de50c917a..d448297059 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 5ac2401d662bca52cac529c0620a39930565a7c7 -tools.zh.md: a081c58a2ba4df50b035a4dae8fa7f9ab7d9de1a +tools.md: 9939e8ab9fff9fa5bd23fd370e07f6296a608824 +tools.zh.md: 52e812a35d6932a5ed00a86a3d3fe71460b26cd4 diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 5ac2401d66..9939e8ab9f 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -157,7 +157,7 @@ Registration is a trusted same-process contract. The registry borrows the typed ```ts type-equiv /** * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * scoped registrations or the reserved PTC mode transport. */ interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -195,11 +195,11 @@ interface ToolExecutionInput { /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent /** - * 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 + * Opaque token of the enclosing transport execution, when one exists. PTC + * 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 + * model-direct call: under `mode: 'ptc'`, 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 ToolRuntime.execute}. */ @@ -252,19 +252,19 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode's bridge additionally exposes each settled sub-dispatch to the `tools/code-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): +PTC mode's bridge additionally exposes each settled sub-dispatch to the `tools/ptc-dispatch-log` waterfall, which may change the durable event's copy of the content (the program's value and model-visible result remain untouched): ```ts type-equiv /** * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the * `tool/code-dispatch` event's copy changes. */ -interface CodeDispatchLog { +interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ @@ -488,7 +488,7 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v * declaration covers every agent joined under it. * * Scoped only, and one declaration per scope: this is how an agent preset - * composes Code Mode agents beside native ones in the same process, and a + * composes PTC mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. @@ -598,33 +598,6 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - - -#### `tools/code-dispatch-log` — waterfall - -Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - -```ts cordis-catalog -/** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/code-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall - */ -'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise -``` - -Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) - -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - #### `tools/execute` — waterfall @@ -697,6 +670,33 @@ Types: [Scoped](scope.md) Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + + +#### `tools/ptc-dispatch-log` — waterfall + +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +``` + +Types: [ContentBlock](llm-streaming.md) · [Scoped](scope.md) + +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + #### `tools/result` — emit diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index a081c58a2b..52e812a35d 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -157,7 +157,7 @@ type InferArgs = InferProperties ```ts type-equiv /** * Per-scope filter over global tools. Restrictions intersect and do not affect - * scoped registrations or the reserved Code Mode transport. + * scoped registrations or the reserved PTC mode transport. */ interface ToolRestriction { /** Global tool names that stay visible; everything else is removed. */ @@ -195,11 +195,11 @@ interface ToolExecutionInput { /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent /** - * 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 + * Opaque token of the enclosing transport execution, when one exists. PTC + * 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 + * model-direct call: under `mode: 'ptc'`, 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 ToolRuntime.execute}. */ @@ -252,19 +252,19 @@ type ToolExecutionMode = | { kind: 'exclusive' } ``` -Code Mode 的桥接层还会把每个已结算的子分派暴露给 `tools/code-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): +PTC mode 的桥接层还会把每个已结算的子分派暴露给 `tools/ptc-dispatch-log` waterfall,该 waterfall 可以更改持久事件所存的内容副本(程序取得的值和模型可见结果均不受影响): ```ts type-equiv /** * One settled `run_code` sub-dispatch about to be logged, as seen by the - * `tools/code-dispatch-log` waterfall: the parent execution (session owner, + * `tools/ptc-dispatch-log` waterfall: the parent execution (session owner, * outer call identity), the sub-call identity, and the outcome whose durable * copy a listener may reshape. `content` is the RENDERED result projection * (what a native `tool/result` would carry) — the program itself received * the structured `value` (or just the error message on failure); only the * `tool/code-dispatch` event's copy changes. */ -interface CodeDispatchLog { +interface PtcDispatchLog { /** The outer `run_code` execution. */ readonly exec: ToolExecution /** The calling agent (the scope routing key and the spill owner), when the outer call has one. */ @@ -488,7 +488,7 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v * declaration covers every agent joined under it. * * Scoped only, and one declaration per scope: this is how an agent preset - * composes Code Mode agents beside native ones in the same process, and a + * composes PTC mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. @@ -598,33 +598,6 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - - -#### `tools/code-dispatch-log` — waterfall - -Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - -```ts cordis-catalog -/** - * Allow a listener to replace content in the DURABLE LOG COPY of one - * `run_code` sub-dispatch outcome before the bridge appends its - * `tool/code-dispatch` event. `next()` keeps the - * content unchanged; a listener may return replacement blocks (e.g. the - * spill policy's preview + locator for an oversized text result). Only the - * logged copy is affected — the program already received the complete - * value, and the model sees neither. A throwing listener is contained: - * the bridge falls back to logging the original settled content. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. - * @param dispatch - the parent execution, sub-call identity, and the settled content to log. - * @mode waterfall - */ -'tools/code-dispatch-log'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise -``` - -Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) - -Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) - #### `tools/execute` — waterfall @@ -697,6 +670,33 @@ Types: [Scoped](scope.zh.md) Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + + +#### `tools/ptc-dispatch-log` — waterfall + +Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy's preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + +```ts cordis-catalog +/** + * Allow a listener to replace content in the DURABLE LOG COPY of one + * `run_code` sub-dispatch outcome before the bridge appends its + * `tool/code-dispatch` event. `next()` keeps the + * content unchanged; a listener may return replacement blocks (e.g. the + * spill policy's preview + locator for an oversized text result). Only the + * logged copy is affected — the program already received the complete + * value, and the model sees neither. A throwing listener is contained: + * the bridge falls back to logging the original settled content. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's dispatches. + * @param dispatch - the parent execution, sub-call identity, and the settled content to log. + * @mode waterfall + */ +'tools/ptc-dispatch-log'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise +``` + +Types: [ContentBlock](llm-streaming.zh.md) · [Scoped](scope.zh.md) + +Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts) + #### `tools/result` — emit diff --git a/packages/core/tools/src/types.ts b/packages/core/tools/src/types.ts index dfdf34f37f..84052261ab 100644 --- a/packages/core/tools/src/types.ts +++ b/packages/core/tools/src/types.ts @@ -7,6 +7,11 @@ import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +/** Payload recorded when one nested PTC mode Tool dispatch starts. */ +export interface PtcDispatchStartEventData { + rootCallId: ToolCallId + parentCallId: ToolCallId + subCallId: ToolCallId name: string arguments: unknown } diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4012db1069..8edd7adb9d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2501,7 +2501,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'presentAs(mode: ToolPresentationMode): () => void', - description: 'Present the calling scope\'s tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset\'s standing declaration covers every agent joined under it.\n\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.', + description: 'Present the calling scope\'s tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset\'s standing declaration covers every agent joined under it.\n\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.', parameters: [{ name: 'mode', description: 'the presentation the covered agents\' models see.' }], returns: 'the exact disposer that restores the deployment default.', }, @@ -3274,14 +3274,6 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent\'s next assembly, so a scoped listener subscribing here sees every change, not just its own scope\'s.', parameters: [], }, - { - name: 'tools/code-dispatch-log', - mode: 'waterfall', - signature: '\'tools/code-dispatch-log\'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise', - summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', - description: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy\'s preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.', - parameters: [{ name: 'dispatch', description: 'the parent execution, sub-call identity, and the settled content to log.' }], - }, { name: 'tools/execute', mode: 'waterfall', @@ -3306,6 +3298,14 @@ export const EVENT_API: readonly EventApiEntry[] = [ description: 'Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s calls.', parameters: [{ name: 'exec', description: 'the pending call (name, parsed arguments, caller agent).' }], }, + { + name: 'tools/ptc-dispatch-log', + mode: 'waterfall', + signature: '\'tools/ptc-dispatch-log\'(this: Scoped, dispatch: PtcDispatchLog, next: () => Promise): Promise', + summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.', + description: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event. `next()` keeps the content unchanged; a listener may return replacement blocks (e.g. the spill policy\'s preview + locator for an oversized text result). Only the logged copy is affected — the program already received the complete value, and the model sees neither. A throwing listener is contained: the bridge falls back to logging the original settled content. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.', + parameters: [{ name: 'dispatch', description: 'the parent execution, sub-call identity, and the settled content to log.' }], + }, { name: 'tools/result', mode: 'emit', @@ -3610,10 +3610,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'CodeBindingNamespace', declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record;\n errorClass?: CodeBindingErrorClass;\n}', }, - { - name: 'CodeDispatchLog', - declaration: 'export interface CodeDispatchLog {\n readonly exec: ToolExecution;\n readonly agent?: Agent;\n readonly subCallId: ToolCallId;\n readonly name: string;\n readonly isError: boolean;\n readonly content: ContentBlock[];\n}', - }, { name: 'CodeJsonValue', declaration: 'export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | {\n [key: string]: CodeJsonValue;\n};', @@ -4582,6 +4578,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'PruneResult', declaration: 'export interface PruneResult {\n readonly pruned: readonly PrunedEntry[];\n readonly charsRemoved: number;\n}', }, + { + name: 'PtcDispatchLog', + declaration: 'export interface PtcDispatchLog {\n readonly exec: ToolExecution;\n readonly agent?: Agent;\n readonly subCallId: ToolCallId;\n readonly name: string;\n readonly isError: boolean;\n readonly content: ContentBlock[];\n}', + }, { name: 'ReadFileLine', declaration: 'export interface ReadFileLine {\n number: number;\n text: string;\n}', @@ -5728,7 +5728,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ToolPresentationMode', - declaration: 'export type ToolPresentationMode = \'native\' | \'code\' | \'both\';', + declaration: 'export type ToolPresentationMode = \'native\' | \'ptc\' | \'both\';', }, { name: 'ToolProviderResult', diff --git a/snapshots/session/cordis-inspect-jsdoc/session.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.jsonl index 0791125afe..321b8ec565 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.jsonl @@ -18,7 +18,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'code' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} From ee42efbccd5e67dce6581c3d7df1c9f0ae34edb2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:48:58 +0800 Subject: [PATCH 123/130] test(snapshot): re-record cordis-inspect-jsdoc after the seq-range projection Master's session persistence projection now writes sourceEventSeqs as compressed ranges; the recorded replay fixture must match the new output. --- snapshots/session/cordis-inspect-jsdoc/session.jsonl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snapshots/session/cordis-inspect-jsdoc/session.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.jsonl index 321b8ec565..cf5f9ecf93 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,16]],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -26,6 +26,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[22,26]],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From c3904faee097dc25616bfcd3dce989c14730b663 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:30:20 +0800 Subject: [PATCH 124/130] test(snapshot): restore canonical packed fixture layout The DSH_SNAPSHOT refresh recorded the internal seq-range form; the canonical fixture layout expands those ranges. Apply the mechanical migration instead. --- snapshots/session/cordis-inspect-jsdoc/session.jsonl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snapshots/session/cordis-inspect-jsdoc/session.jsonl b/snapshots/session/cordis-inspect-jsdoc/session.jsonl index cf5f9ecf93..321b8ec565 100644 --- a/snapshots/session/cordis-inspect-jsdoc/session.jsonl +++ b/snapshots/session/cordis-inspect-jsdoc/session.jsonl @@ -16,7 +16,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[12,16]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect_query","arguments":"{\"platform\":\"host\",\"provider\":\"Service\",\"method\":\"listService\",\"input\":{\"service\":\"tools\"}}"}} {"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes PTC mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": [\n {\n \"name\": \"Agent\",\n \"declaration\": \"export interface Agent {\\n readonly id: SessionId;\\n}\"\n },\n {\n \"name\": \"AssistantProvenance\",\n \"declaration\": \"export interface AssistantProvenance {\\n provider: string;\\n model: string;\\n replayState?: unknown;\\n}\"\n },\n {\n \"name\": \"Branded\",\n \"declaration\": \"export type Branded = string & {\\n readonly [BRAND]: B;\\n};\"\n },\n {\n \"name\": \"ContextFormed\",\n \"declaration\": \"export type ContextFormed = {\\n readonly form?: never;\\n} | {\\n readonly form: 'instructions';\\n} | {\\n readonly form: 'catalog';\\n} | {\\n readonly form: 'snapshot';\\n readonly sections: readonly ContextSnapshotSection[];\\n} | {\\n readonly form: 'notice';\\n readonly summary: string;\\n} | {\\n readonly form: 'relay';\\n} | {\\n readonly form: 'recall';\\n};\"\n },\n {\n \"name\": \"ContextSnapshotSection\",\n \"declaration\": \"export interface ContextSnapshotSection {\\n readonly name: string;\\n readonly text: string;\\n}\"\n },\n {\n \"name\": \"DiffCallView\",\n \"declaration\": \"export interface DiffCallView {\\n card: 'diff';\\n title: string;\\n diffs: FileDiff[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"DiffResultView\",\n \"declaration\": \"export interface DiffResultView {\\n card: 'diff';\\n title?: string;\\n diffs: FileDiff[];\\n}\"\n },\n {\n \"name\": \"FileDiff\",\n \"declaration\": \"export interface FileDiff {\\n path: string;\\n oldText: string | null;\\n newText: string;\\n}\"\n },\n {\n \"name\": \"FileLocation\",\n \"declaration\": \"export interface FileLocation {\\n path: string;\\n line?: number;\\n}\"\n },\n {\n \"name\": \"GenericCallView\",\n \"declaration\": \"export interface GenericCallView {\\n card: 'generic';\\n title: string;\\n kind?: ToolCallKind;\\n rawInput?: unknown;\\n content?: ContentBlock[];\\n locations?: FileLocation[];\\n}\"\n },\n {\n \"name\": \"GenericResultView\",\n \"declaration\": \"export interface GenericResultView {\\n card: 'generic';\\n title?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"JsonSchemaNode\",\n \"declaration\": \"export interface JsonSchemaNode {\\n type?: JsonSchemaType;\\n oneOf?: JsonSchemaNode[];\\n properties?: Record;\\n required?: string[];\\n additionalProperties?: boolean;\\n items?: JsonSchemaNode;\\n enum?: JsonSchemaScalar[];\\n const?: JsonSchemaScalar;\\n description?: string;\\n title?: string;\\n default?: JsonValue;\\n examples?: JsonValue;\\n}\"\n },\n {\n \"name\": \"JsonSchemaScalar\",\n \"declaration\": \"export type JsonSchemaScalar = string | number | boolean | null;\"\n },\n {\n \"name\": \"JsonSchemaType\",\n \"declaration\": \"export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\"\n },\n {\n \"name\": \"JsonValue\",\n \"declaration\": \"export type JsonValue = null | boolean | number | string | JsonValue[] | {\\n [key: string]: JsonValue;\\n};\"\n },\n {\n \"name\": \"Message\",\n \"declaration\": \"export interface Message {\\n readonly id: MessageId;\\n readonly role: 'system' | 'user' | 'assistant';\\n readonly content: ContentBlock[];\\n readonly source: MessageSource;\\n}\"\n },\n {\n \"name\": \"MessageId\",\n \"declaration\": \"export type MessageId = Branded<'MessageId'>;\"\n },\n {\n \"name\": \"MessageSource\",\n \"declaration\": \"export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\"\n },\n {\n \"name\": \"MessageSourceMap\",\n \"declaration\": \"export interface MessageSourceMap {\\n user: {\\n kind: 'user';\\n };\\n plugin: {\\n kind: 'plugin';\\n plugin: string;\\n } & ContextFormed;\\n model: ModelMessageSource;\\n tool: ToolMessageSource;\\n}\"\n },\n {\n \"name\": \"ModelMessageSource\",\n \"declaration\": \"export interface ModelMessageSource extends AssistantProvenance {\\n kind: 'model';\\n}\"\n },\n {\n \"name\": \"ReadFileLine\",\n \"declaration\": \"export interface ReadFileLine {\\n number: number;\\n text: string;\\n}\"\n },\n {\n \"name\": \"ReadResultView\",\n \"declaration\": \"export interface ReadResultView {\\n card: 'read';\\n title?: string;\\n path: string;\\n offset: number;\\n lines: ReadFileLine[];\\n totalLines: number;\\n lang?: string;\\n content?: ContentBlock[];\\n}\"\n },\n {\n \"name\": \"ScopeKey\",\n \"declaration\": \"export type ScopeKey = object;\"\n },\n {\n \"name\": \"SearchFileMatches\",\n \"declaration\": \"export interface SearchFileMatches {\\n path: string;\\n matches: SearchLineMatch[];\\n}\"\n },\n {\n \"name\": \"SearchLineMatch\",\n \"declaration\": \"export interface SearchLineMatch {\\n lineNumber: number;\\n line: string;\\n}\"\n },\n {\n \"name\": \"SearchMatchesResultView\",\n \"declaration\": \"export interface SearchMatchesResultView {\\n card: 'search';\\n shape: 'matches';\\n title?: string;\\n files: SearchFileMatches[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchPathsResultView\",\n \"declaration\": \"export interface SearchPathsResultView {\\n card: 'search';\\n shape: 'paths';\\n title?: string;\\n paths: string[];\\n truncated: boolean;\\n total: number;\\n}\"\n },\n {\n \"name\": \"SearchResultView\",\n \"declaration\": \"export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\"\n },\n {\n \"name\": \"SessionId\",\n \"declaration\": \"export type SessionId = Branded<'SessionId'>;\"\n },\n {\n \"name\": \"TerminalCallView\",\n \"declaration\": \"export interface TerminalCallView {\\n card: 'terminal';\\n title: string;\\n description?: string;\\n cwd?: string;\\n}\"\n },\n {\n \"name\": \"TerminalResultView\",\n \"declaration\": \"export interface TerminalResultView {\\n card: 'terminal';\\n title?: string;\\n output?: string;\\n exitCode?: number;\\n signal?: string;\\n}\"\n },\n {\n \"name\": \"ToolCallKind\",\n \"declaration\": \"export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\"\n },\n {\n \"name\": \"ToolCallView\",\n \"declaration\": \"export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\"\n },\n {\n \"name\": \"ToolDefinition\",\n \"declaration\": \"export interface ToolDefinition extends ToolSchema {\\n readonly output: ToolOutputDefinition;\\n execute(args: unknown, exec: ToolRunContext): Promise;\\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\\n timeoutMs?: number;\\n isConcurrencySafe?(args: unknown): boolean;\\n presentCall?(args: unknown): ToolCallView | undefined;\\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\\n}\"\n },\n {\n \"name\": \"ToolErrorInfo\",\n \"declaration\": \"export interface ToolErrorInfo {\\n name: string;\\n code: string;\\n}\"\n },\n {\n \"name\": \"ToolExecution\",\n \"declaration\": \"export interface ToolExecution extends ToolExecutionInput {\\n readonly rootCallId: ToolCallId;\\n readonly token: ToolExecutionToken;\\n}\"\n },\n {\n \"name\": \"ToolExecutionFailure\",\n \"declaration\": \"export interface ToolExecutionFailure {\\n readonly isError: true;\\n readonly error: ToolFailure;\\n readonly value?: never;\\n readonly content: ContentBlock[];\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: never;\\n}\"\n },\n {\n \"name\": \"ToolExecutionInput\",\n \"declaration\": \"export interface ToolExecutionInput {\\n readonly callId: ToolCallId;\\n readonly rootCallId?: ToolCallId;\\n readonly name: string;\\n readonly arguments: unknown;\\n readonly agent?: Agent;\\n readonly parent?: ToolExecutionToken;\\n readonly signal: AbortSignal;\\n}\"\n },\n {\n \"name\": \"ToolExecutionMode\",\n \"declaration\": \"export type ToolExecutionMode = {\\n kind: 'parallel';\\n} | {\\n kind: 'exclusive';\\n};\"\n },\n {\n \"name\": \"ToolExecutionResult\",\n \"declaration\": \"export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\"\n },\n {\n \"name\": \"ToolExecutionSuccess\",\n \"declaration\": \"export interface ToolExecutionSuccess {\\n readonly isError: false;\\n readonly value: JsonValue;\\n readonly content: ContentBlock[];\\n readonly error?: never;\\n readonly meta?: JsonValue;\\n readonly additionalContexts?: UserMessage[];\\n readonly concludesTurn?: true;\\n}\"\n },\n {\n \"name\": \"ToolExecutionToken\",\n \"declaration\": \"export type ToolExecutionToken = symbol & {\\n readonly [toolExecutionTokenBrand]: true;\\n};\"\n },\n {\n \"name\": \"ToolFailure\",\n \"declaration\": \"export interface ToolFailure {\\n message: string;\\n info?: ToolErrorInfo;\\n}\"\n },\n {\n \"name\": \"ToolGuard\",\n \"declaration\": \"export type ToolGuard = (execution: Readonly) => string | undefined;\"\n },\n {\n \"name\": \"ToolMessageSource\",\n \"declaration\": \"export interface ToolMessageSource {\\n kind: 'tool';\\n callId: ToolCallId;\\n}\"\n },\n {\n \"name\": \"ToolOutputDefinition\",\n \"declaration\": \"export interface ToolOutputDefinition {\\n readonly schema: JsonSchemaNode;\\n render(args: unknown, value: JsonValue): ContentBlock[];\\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolPresentationMode\",\n \"declaration\": \"export type ToolPresentationMode = 'native' | 'ptc' | 'both';\"\n },\n {\n \"name\": \"ToolRestriction\",\n \"declaration\": \"export interface ToolRestriction {\\n readonly allow?: readonly string[];\\n readonly deny?: readonly string[];\\n}\"\n },\n {\n \"name\": \"ToolResult\",\n \"declaration\": \"export interface ToolResult {\\n content: ContentBlock[];\\n isError: boolean;\\n meta?: JsonValue;\\n}\"\n },\n {\n \"name\": \"ToolResultView\",\n \"declaration\": \"export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\"\n },\n {\n \"name\": \"ToolRunContext\",\n \"declaration\": \"export interface ToolRunContext extends ToolExecution {\\n deferContext(context: UserMessage): void;\\n concludeTurn(): void;\\n}\"\n },\n {\n \"name\": \"ToolSchema\",\n \"declaration\": \"export interface ToolSchema {\\n name: string;\\n description: string;\\n parameters: Record;\\n}\"\n },\n {\n \"name\": \"UserMessage\",\n \"declaration\": \"export interface UserMessage extends Message {\\n readonly role: 'user';\\n}\"\n },\n {\n \"name\": \"WebFetchResultView\",\n \"declaration\": \"export interface WebFetchResultView {\\n card: 'web';\\n kind: 'fetch';\\n title?: string;\\n url: string;\\n statusCode: number;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebResultView\",\n \"declaration\": \"export type WebResultView = WebSearchResultView | WebFetchResultView;\"\n },\n {\n \"name\": \"WebSearchResultView\",\n \"declaration\": \"export interface WebSearchResultView {\\n card: 'web';\\n kind: 'search';\\n title?: string;\\n sources: WebSource[];\\n answer?: string;\\n truncated: boolean;\\n}\"\n },\n {\n \"name\": \"WebSource\",\n \"declaration\": \"export interface WebSource {\\n url: string;\\n title?: string;\\n snippet?: string;\\n publishedAt?: string;\\n}\"\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{message:4}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} @@ -26,6 +26,6 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[[22,26]],"surfaceOp":"append"} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} From cf12723ba852fe3b0694ae3c2372252734e6689c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:09:05 +0800 Subject: [PATCH 125/130] docs: re-record pairing hashes after the master rebase merge The rebase merged master's SDK-example and telemetry prose with the ptc renames in the tools and CLI reference READMEs and the executor collapse note; re-record their pair hashes. --- .../bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml | 4 ++-- apps/cli/reference/README.i18n.yaml | 4 ++-- packages/core/tools/README.i18n.yaml | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml index 2c1974c57b..528aa1f781 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.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-07-ptc-executor-collapse.md -2026-08-07-ptc-executor-collapse.md: cbdbc916652f3b5b7ca339f3c856caae5f898f96 -2026-08-07-ptc-executor-collapse.zh.md: 9a6173198e17210985a77e34056291566dec93fa +2026-08-07-ptc-executor-collapse.md: e034d30ff54673ecef98faf5c632b92c2d39aa2f +2026-08-07-ptc-executor-collapse.zh.md: 03f93a323533b8507043c2fa8653b4c51c5fd56e diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index b4c3dc3f3c..0a1463fc49 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: 03ec7c3a14b35f20b748a1534ae7f98861cc6a49 -README.zh.md: d8655832656d73d6b7193be918da422648c50e84 +README.md: f5cbe1659e5181cdaa6eaefcdb9bd8c8fe289e6d +README.zh.md: cf040f085241f0af58eb3db485bcedd4316726be diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 3c177a036c..02bfaf305a 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: ed62ac8314e3bbd3720921738b9cbe3b0ee7315a -README.zh.md: 1c42cbffe27bf2affdf7818085896c6d70a5655c +README.md: f586b926ec9a8e426f48b62e4d03b1661201a76a +README.zh.md: 15282a7dff6d8da3a2a43315f1bc2ebe8dcd9a60 From 558b6d9193d27f5131534f5e39ab7deb67541a93 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:37:58 +0800 Subject: [PATCH 126/130] fix(notices): restore the SDK 0.3.241 platform payload rows The rename commit regenerated the notices file against a stale local install (0.3.220); the lockfile and CI install resolve 0.3.241. --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ce770592a0..616dcd1909 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies From 84dd2447f34a7e4c49b68a1af82e79c7082fe3d6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:22:02 +0800 Subject: [PATCH 127/130] docs: point note references at the surviving RPC test homes Master removed the ApiProxy package; its former test paths now live in the client connection, API gateway, and settings controller test directories. Update the three notes' references so verify-package-paths resolves. --- ...2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml | 4 ++-- .../2026-08-04-draft-provider-endpoint-interrogation.md | 2 +- .../2026-08-04-draft-provider-endpoint-interrogation.zh.md | 2 +- .../2026-08-09-headless-direct-core-entry-point.i18n.yaml | 4 ++-- .../2026-08-06-continuable-subagent-interrupt.i18n.yaml | 4 ++-- .../feature/2026-08-06-continuable-subagent-interrupt.md | 2 +- .../feature/2026-08-06-continuable-subagent-interrupt.zh.md | 2 +- .../process/2026-07-20-gui-testing-system.i18n.yaml | 4 ++-- .../implemented/process/2026-07-20-gui-testing-system.md | 2 +- .../implemented/process/2026-07-20-gui-testing-system.zh.md | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 9647bf2513..26dce064e4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 0132dba5888faa1b9e6a4e59b45ee56a259eeef7 -2026-08-04-draft-provider-endpoint-interrogation.zh.md: 8cea5d2809611696606113b2e38578f1b54b4e09 +2026-08-04-draft-provider-endpoint-interrogation.md: a93c5f79a0e4f824be2f1e96853204d27cd22845 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: f8e9002b5267a9d13425601eaefff29e45662699 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 0132dba588..a93c5f79a0 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -47,4 +47,4 @@ What it costs: the wire gained a third secret-carrying payload, so the configura ## Testing -`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/host/apiproxy/tests/api-proxy-config.spec.ts` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, and the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its own where the draft has none and a typed key winning over it, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/api/settings-controller/tests/` covers the RPC over a real proxy: the draft reaching its namespace whole, absent fields staying absent, no namespace or credential being written, and a failure surfacing as `model-discovery-failed` with the credential absent from the serialized error. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index 8cea5d2809..f8e9002b52 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -47,4 +47,4 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 ## Testing -`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/host/apiproxy/tests/api-proxy-config.spec.ts` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化,以及 `NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、草稿没带密钥时已配置路由自行取用凭据且键入的密钥压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/api/settings-controller/tests/` 在真实 proxy 上覆盖该 RPC:草稿完整抵达其 namespace、缺席字段保持缺席、没有 namespace 或凭据被写入,以及失败以 `model-discovery-failed` 呈现且序列化后的错误里不含凭据。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index d5eccf48bc..13317f8219 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 0234f8b7843be172eafe8bd4c38b3544f5cfb07b -2026-08-09-headless-direct-core-entry-point.zh.md: f864c94d69adc67fa0d3836af834e1ebde151063 +2026-08-09-headless-direct-core-entry-point.md: ca3effd6c054ba70bb2f5d4db0794386c37cbcab +2026-08-09-headless-direct-core-entry-point.zh.md: 4da5c9539358dd275bcde18c23907c31d35d2519 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml index 7a0f30b2e1..72b1b1301e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.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-06-continuable-subagent-interrupt.md -2026-08-06-continuable-subagent-interrupt.md: b1cd1fb19317ddb96a15f4138c56fafc69cc9967 -2026-08-06-continuable-subagent-interrupt.zh.md: 40197f6b03f39910a6319f28f8f5aa8faac06ed7 +2026-08-06-continuable-subagent-interrupt.md: dfc8df2b0e32defa97b1596bc8d95d8b34d8f65e +2026-08-06-continuable-subagent-interrupt.zh.md: 3ac2ed34e50165a8958e4776e11f9cb863847276 diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md index b1cd1fb193..dfc8df2b0e 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.md @@ -45,4 +45,4 @@ The model-facing `interrupt_agent(agent_id)` tool in `dsh-tool-subagent-control` ## Testing -Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/host/apiproxy/tests` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold real child turns open with replay hang entries and prove the parent-offline UI-to-RPC abort path, queued Send, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshot executes `list_agents({ scope: 'descendants' })` and `interrupt_agent` through the assembled application against one settled child, while recorded request headers continue to pin both schemas. +Core coverage in `packages/subagent/subagent/tests/continuation.spec.ts` proves the durable `turn/end` abort, parked-then-FIFO-resumed queue, untouched descendant, both authority kinds with their cancel causes, self/sibling/stale/non-ancestor rejection, absent/one-shot/disposal-race no-ops, and the unchanged `keepInbox` loop behavior. Host coverage in `packages/api/gateway/tests/` proves the RPC calls only the core primitive (no agents/catalog/history reads), the `subagent-unauthorized`/`internal` mappings, the wire schema's continuable-mode fence, and carrier round-trips. Client coverage pins the address-routed `Session.cancel()`, the InputBar's independent Send and Stop actions with the parent-offline locked-input/Send state, and the read-only-composer selector's running exception; the keyless assembled Web scenarios (`apps/web/tests/subagent-interrupt.e2e.ts`, `subagent-interrupt-ui.e2e.ts`) hold real child turns open with replay hang entries and prove the parent-offline UI-to-RPC abort path, queued Send, the parked follow-up, and the FIFO resume end to end. Tool coverage in `packages/subagent/tool-subagent-control/tests` proves direct and deep ancestor interrupts with the `parent` cause and parked queue, self/sibling/stranger rejection without touching the target, absent-target no-ops without cold resume, and the descendants listing's pre-order positions; the keyless ACP snapshot executes `list_agents({ scope: 'descendants' })` and `interrupt_agent` through the assembled application against one settled child, while recorded request headers continue to pin both schemas. diff --git a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md index 40197f6b03..3ac2ed34e5 100644 --- a/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md +++ b/.agents/notes/implemented/feature/2026-08-06-continuable-subagent-interrupt.zh.md @@ -45,4 +45,4 @@ Host RPC `subagent.interrupt` 接收 continuable 的 `SubagentAddress` 并返回 ## 测试 -`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/host/apiproxy/tests` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)通过多条 replay hang 条目保持多个真实 child 轮次打开,端到端证明 parent 离线时从 UI 到 RPC 的中止路径、Send 入队、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照通过组装应用,针对一个已结算的 child 执行 `list_agents({ scope: 'descendants' })` 与 `interrupt_agent`,同时已录制的请求 header 仍固定这两个 schema。 +`packages/subagent/subagent/tests/continuation.spec.ts` 中的核心覆盖证明了持久化 `turn/end` 中止、队列先暂停后按 FIFO 恢复、后代不受影响、两种授权及其取消 cause、self/sibling/stale/非 ancestor 拒绝、absent/一次性/disposal 竞态 no-op,以及 `keepInbox` 循环行为不变。`packages/api/gateway/tests/` 中的 Host 覆盖证明 RPC 只调用核心原语(不读 agents/目录/历史)、`subagent-unauthorized`/`internal` 映射、wire schema 的 continuable 模式围栏以及 carrier 往返。客户端覆盖固定按地址路由的 `Session.cancel()`、InputBar 的独立 Send 与 Stop 操作及 parent 离线时锁定输入区和 Send 的状态,以及只读 composer selector 的运行例外;keyless 组装 Web 场景(`apps/web/tests/subagent-interrupt.e2e.ts`、`subagent-interrupt-ui.e2e.ts`)通过多条 replay hang 条目保持多个真实 child 轮次打开,端到端证明 parent 离线时从 UI 到 RPC 的中止路径、Send 入队、follow-up 暂停以及 FIFO 恢复。`packages/subagent/tool-subagent-control/tests` 中的工具覆盖证明直接与更深 ancestor 以 `parent` cause 中断并暂停队列、self/sibling/陌生调用方被拒绝且不触碰目标、目标不存在时 no-op 且不冷恢复,以及 descendants 列表的 pre-order 位置;keyless ACP 快照通过组装应用,针对一个已结算的 child 执行 `list_agents({ scope: 'descendants' })` 与 `interrupt_agent`,同时已录制的请求 header 仍固定这两个 schema。 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index df45c55f57..dcd1be33a0 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-20-gui-testing-system.md -2026-07-20-gui-testing-system.md: 8656507ba85b187fea333b100762d4250256845f -2026-07-20-gui-testing-system.zh.md: d9dee0d531bfebafca02bbe765a271ee39e6e6a0 +2026-07-20-gui-testing-system.md: 41cf4fa93b63c4cf9bf9e1327983d36c0c98ebfb +2026-07-20-gui-testing-system.zh.md: 603124368e49cab8755a9c93885e85b2e288b0a3 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index 8656507ba8..41cf4fa93b 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -18,7 +18,7 @@ Cut along the architecture's natural test hooks into three tiers, bottom-up: | Tier | Under test | Key technique | File location | |---|---|---|---| -| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | +| 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/client/connection/tests/` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | | 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md), [required CI gate](../testing/2026-07-30-web-browser-snapshot-ci-gate.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index d9dee0d531..603124368e 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -18,7 +18,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 | 层 | 被测物 | 关键手段 | 文件落点 | |---|---|---|---| -| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD 类型/SSE(Server-Sent Events)流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | +| 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD 类型/SSE(Server-Sent Events)流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/client/connection/tests/` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | | 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以确定性方式驱动跨插件状态变化;另有最简 Playwright 冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture(测试前置数据),与会话区 aria 预期输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md)、[必需 CI 门禁](../testing/2026-07-30-web-browser-snapshot-ci-gate.zh.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | From b7c71d805af53d3302d4ac733e970683e7c9fc75 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:16:29 +0800 Subject: [PATCH 128/130] docs(zh): sync remaining code mode-value prose to ptc The review bot found stale 'code' configuration values in the zh tools and agent-tool-presentation READMEs and the zh tool catalog, which the runtime schema already rejects in favor of 'ptc'. --- docs/tool-catalog.i18n.yaml | 2 +- docs/tool-catalog.zh.md | 2 +- packages/core/agent-tool-presentation/README.i18n.yaml | 2 +- packages/core/agent-tool-presentation/README.zh.md | 4 ++-- packages/core/tools/README.i18n.yaml | 2 +- packages/core/tools/README.zh.md | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 5a00cc6efa..392f448802 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md tool-catalog.md: 91ff093e79cc05a2c08b5aa1130378440cd963f3 -tool-catalog.zh.md: 2cded94f48a01dca34f9433df48232aa9d726d58 +tool-catalog.zh.md: 35daf5a7c6b1c27975721c0b9d8ddfe71eaeb803 diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 2cded94f48..35daf5a7c6 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -20,7 +20,7 @@ | 工具包 | 模型可见名称 | 依赖 | 写入/影响 | 随产品发布的别名 | 部署说明 | | --- | --- | --- | --- | --- | --- | | `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 | -| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `code` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | +| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.codeRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 | | `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 | diff --git a/packages/core/agent-tool-presentation/README.i18n.yaml b/packages/core/agent-tool-presentation/README.i18n.yaml index 26e0117028..7f29e5c109 100644 --- a/packages/core/agent-tool-presentation/README.i18n.yaml +++ b/packages/core/agent-tool-presentation/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-tool-presentation/README.md README.md: c57938f2b32bead9558b283112c09e8a46b6e866 -README.zh.md: bbe7801fba2e3f284be0146c1375f73a5dc00009 +README.zh.md: ac511f0793c1f883d539fd05cc53655869f19f5d diff --git a/packages/core/agent-tool-presentation/README.zh.md b/packages/core/agent-tool-presentation/README.zh.md index bbe7801fba..ac511f0793 100644 --- a/packages/core/agent-tool-presentation/README.zh.md +++ b/packages/core/agent-tool-presentation/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -把这一行加入 agent preset,以固定每个加入该 preset 的 agent 看到其工具的方式。`native` 以函数定义的形式呈现每个可见工具 schema;`code` 只呈现 `run_code` 传输、一份生成的 SDK 以及「只有 `run_code` 可被直接调用」这条规则;`both` 同时呈现两种形态。未作声明的 agent 会拿到 [`dsh-tools`](../tools/README.zh.md) 那一行上的部署级 `mode`。 +把这一行加入 agent preset,以固定每个加入该 preset 的 agent 看到其工具的方式。`native` 以函数定义的形式呈现每个可见工具 schema;`ptc` 只呈现 `run_code` 传输、一份生成的 SDK 以及「只有 `run_code` 可被直接调用」这条规则;`both` 同时呈现两种形态。未作声明的 agent 会拿到 [`dsh-tools`](../tools/README.zh.md) 那一行上的部署级 `mode`。 ### 把这一行加入 preset @@ -37,7 +37,7 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| -| `mode` | 必填 | `native`——每个 schema;`code`——`run_code` 加生成 SDK;`both`——两种形态 | +| `mode` | 必填 | `native`——每个 schema;`ptc`——`run_code` 加生成 SDK;`both`——两种形态 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-agent-tool-presentation)是每个受支持字段的穷尽式真源。`mode` 是必填而非有默认值,因为不带这一行的 preset 会继承部署默认值。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 02bfaf305a..feacbdc2e4 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md README.md: f586b926ec9a8e426f48b62e4d03b1661201a76a -README.zh.md: 15282a7dff6d8da3a2a43315f1bc2ebe8dcd9a60 +README.zh.md: 5f422dd848ee9a6e8e0afe96d3dd03238ecc3857 diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index 15282a7dff..5f422dd848 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -61,7 +61,7 @@ ctx.tools.register(defineTool({ ### 配置呈现模式 -`mode` 配置决定模型看到什么:`native`(每个可见 schema)、`code`(只有 `run_code` 加一份生成 SDK)或 `both`。 +`mode` 配置决定模型看到什么:`native`(每个可见 schema)、`ptc`(只有 `run_code` 加一份生成 SDK)或 `both`。 ```yaml - name: '@deepseek-ai/dsh-tools' @@ -122,7 +122,7 @@ ctx.tools.register(defineTool({ ### PTC mode -在 `ptc` 或 `both` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定调用都会在日志中与外层调用关联,重新进入完整工具流水线,并通过复用原生并发约定的每次运行独有池调度。在纯 `code` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md) 拥有该收束约定。 +在 `ptc` 或 `both` 下,注册表公开保留的 `run_code` 传输以及按所加载运行时语言生成的确定性 SDK。每个 SDK 绑定调用都会在日志中与外层调用关联,重新进入完整工具流水线,并通过复用原生并发约定的每次运行独有池调度。在纯 `ptc` 下,模型直呼其他任何可见工具都会在策略之前解析为 `UNKNOWN_TOOL`——通告面与可调用面保持一致。中间绑定值只存在于执行局部;只有外层 `run_code` 结果有硬大小上限。[执行器塌缩 note](../../../.agents/notes/implemented/bug-fix/2026-08-07-ptc-executor-collapse.zh.md) 拥有该收束约定。 ### 扩展点 From 188d77ed4b798650228d146adb5126ef8c29a168 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:24:05 +0800 Subject: [PATCH 129/130] docs: sync remaining code mode-value prose to ptc in notes and spill README Review bot findings: DSH_TOOLS_MODE values, the wire-replacement wording, 'code-only' mode references, and the spill README's dispatch-log waterfall name all still named the removed 'code' value. --- .../feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml | 4 ++-- .../feature/2026-07-26-ptc-dispatch-ui-foundation.md | 2 +- .../feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md | 2 +- .../2026-07-31-even-out-shipped-tool-rosters.i18n.yaml | 4 ++-- .../feature/2026-07-31-even-out-shipped-tool-rosters.md | 2 +- .../feature/2026-07-31-even-out-shipped-tool-rosters.zh.md | 2 +- .../notes/proposed/feature/2026-08-04-task-surface.i18n.yaml | 4 ++-- .agents/notes/proposed/feature/2026-08-04-task-surface.md | 4 ++-- .agents/notes/proposed/feature/2026-08-04-task-surface.zh.md | 4 ++-- packages/spill/spill-policy/README.i18n.yaml | 4 ++-- packages/spill/spill-policy/README.md | 4 ++-- packages/spill/spill-policy/README.zh.md | 4 ++-- 12 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml index e5ffbfea2f..4330c215e1 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-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-ptc-dispatch-ui-foundation.md -2026-07-26-ptc-dispatch-ui-foundation.md: 9abc3b31c93ed5013f3006d23d55cf4c5db67c57 -2026-07-26-ptc-dispatch-ui-foundation.zh.md: 66b01f2d5e7907386508fd9e633577d34051851f +2026-07-26-ptc-dispatch-ui-foundation.md: 793735a87b734429bfdcba91b0512ce264a8aa1f +2026-07-26-ptc-dispatch-ui-foundation.zh.md: 68cd9c538d71db9f14e2bb4f8eae15049f0c9ad4 diff --git a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md index 9abc3b31c9..793735a87b 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-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 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-presentation 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`|`ptc`|`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-presentation 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-ptc-dispatch-ui-foundation.zh.md b/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md index 66b01f2d5e..68cd9c538d 100644 --- a/.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-ui-foundation.zh.md +++ b/.agents/notes/implemented/feature/2026-07-26-ptc-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 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 +3. **`dsh` 配置树上的 `DSH_TOOLS_MODE` 环境变量**(`native`|`ptc`|`both`;未设置时保持 schema 默认值):`tools` 行通过 `!!js` 读取它,worker 代码运行时则无条件挂载(本项交付时 loader 元数据仍是静态的,因此不存在条件行;后来的 [`disabled` 插值决策](../architecture/2026-08-11-loader-entry-disabled-interpolation.zh.md) 让条件行成为可能,但此处不变——native 启动只是注册该服务,worker 要到每次运行时才 spawn)。这是一个明确标注为临时的配置钩子:设计目标是让 Web UI 拥有按会话的工具模式选择,该目标落地后,这个环境变量随即退役。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index db0e71accf..2f2c4740b0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.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-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: f1ff22fd90e2936c8441d0627d2eb05286f10fbb -2026-07-31-even-out-shipped-tool-rosters.zh.md: a7bb27f4ab66a1ad76b1a2a0852ebbfbc7d3e00a +2026-07-31-even-out-shipped-tool-rosters.md: 4e6b4b0e0ad89ce95d731dac21a8b55a24663be5 +2026-07-31-even-out-shipped-tool-rosters.zh.md: 4be6678cc2f728c6f69cbf97462089a6b45c736a diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index f1ff22fd90..4e6b4b0e0a 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -52,7 +52,7 @@ Beyond the committed tests, both surfaces were driven against a real key from th **Sandbox the TUI in the same change.** Rejected as a separate decision that does not belong in a roster change: the TUI mounts unrestricted executors, and replacing them alters what an existing surface does rather than what it offers. That decision needs its own evidence — not least because the TUI has no `approval/request` answerer, so an escalation there fails closed instead of prompting. -**Enable PTC mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `code` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. +**Enable PTC mode.** Its trust posture is bash-equivalent by design and its tool calls pass the same `tools/pre-execute` gate as bash, so it is not the same call as the model-code tools above. Rejected here anyway: `both` changes every model-visible request on both surfaces, and `ptc` replaces the wire rather than adding to it — either is a presentation decision, not a roster one. **Mount an MCP server by default.** Rejected because a shipped default would have to name one, and any choice spawns a third-party child process on every user's machine outside the sandbox. The dependency ships instead. diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index a7bb27f4ab..4be6678cc2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -52,7 +52,7 @@ Status: implemented **在同一次改动里给 TUI 加沙箱。** 不予采纳,因为这是一个不属于工具清单改动的独立决定:TUI 挂的是不受限执行器,替换它们会改变一个既有 surface 做什么,而非它提供什么。这个决定需要自己的证据——尤其因为 TUI 没有 `approval/request` 的应答方,升权请求在那里会 fail-closed,而不是弹出提示。 -**开启 PTC mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `code` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 +**开启 PTC mode。** 它的信任立场按设计与 bash 同级,工具调用要过与 bash 相同的 `tools/pre-execute` 闸门,所以它与上面那些模型写码工具不是同一个判断。在这里仍被否决:`both` 会改变两个 surface 上每一个模型可见请求,而 `ptc` 是把线路替换而非加一个——两者都是呈现方式的决定,不是工具清单的决定。 **默认挂一台 MCP 服务器。**否决,因为交付默认值必须点名一台,而任何选择都会在每个用户的机器上、在沙箱之外 spawn 一个第三方子进程。改为交付依赖。 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 7c63922271..b90286f6ba 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: db4472e98146902cad59112cee3a9098cb736fa1 -2026-08-04-task-surface.zh.md: 8487c3f5f5b40a55a709373b83eb7425ef5e532f +2026-08-04-task-surface.md: 15a550e2214660a673ff1b71ec0f38bcb005ee8f +2026-08-04-task-surface.zh.md: 5316a2cdc74ea6caa8e22c32f9af2e03802dd84f diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index db4472e981..15a550e221 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -81,7 +81,7 @@ Limits are schema-backed configuration on the Task Surface service. The initial `show_task_surface` accepts `{ model: TaskSurfaceModelV1 }`. The Host parses and normalizes the complete model, rejects the call when that Session already has an open Task Surface, mints `surfaceId`, and returns canonical `{ surfaceId, model }` with the normalized model. `presentationMeta` persists `value.model`, so the projector and executor cannot disagree about normalization. The Native result names the Surface and explains that an ordinary message bypasses it when the client cannot render the panel. The tool then calls `exec.concludeTurn()` so the agent does not continue past the requested human checkpoint. -The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `code`-only profile does not advertise it because PTC mode dispatch is nested and cannot carry its presentation metadata to the outer result. +The tool definition omits `isConcurrencySafe`. Under the existing tool-registry contract, omission classifies every call as an exclusive ordering barrier; no new `ToolDefinition` field is introduced. The tool is composed only in Web profiles that mount both the Host service and Web renderer. Version 1 supports `native` and `both` tool modes; a `ptc`-only profile does not advertise it because PTC mode dispatch is nested and cannot carry its presentation metadata to the outer result. The browser-safe domain package imports the type-only `Branded` primitive from `@deepseek-ai/dsh-brand` and owns all three Task Surface IDs. The canonical value is execution-local under the [canonical tool output contract](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.md). Replay therefore uses `output.presentationMeta(args, value)` to persist this tagged payload with `tool/result.meta`: @@ -260,7 +260,7 @@ The implementation depends on the existing message log, canonical tool output, t ## Acceptance criteria -- A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `code`-only mode does not advertise it. +- A real model in `native` or `both` mode can call one stable `show_task_surface` schema, the call ends its turn, and a capable Web client renders the same normalized model live and after replay; `ptc`-only mode does not advertise it. - The static `TaskSurfaceDock` is the only editor and remains actionable for an active result outside the loaded history window; the keyed toolview remains a read-only transcript summary and replay. A composer takeover hides the still-mounted Dock, preserves its draft, and reveals the same owner after release. - Submitting produces exactly one visible user message per `submissionId`, starts the next turn through normal queue admission, and retains exact branded occurrence correlation while keeping `source.kind: 'user'`; dismissing records one log event and starts no turn. - The queued client row retains the correlated message source. `getActive` exposes `queued` or `claiming` across same-process reconnect; commit closes the projection, while explicit discard clears pending state and leaves the Surface open. Queue-row disappearance alone changes no UI state. Edit and steer are rejected, and remove succeeds only before claim. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index 8487c3f5f5..5316a2cdc7 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -81,7 +81,7 @@ Task Surface 服务通过受 schema 校验的配置定义限制。初始默认 `show_task_surface` 接收 `{ model: TaskSurfaceModelV1 }`。Host 解析并规范化完整模型;若该会话已有一个打开的 Task Surface,则拒绝调用;否则生成 `surfaceId`,并返回带规范化模型的规范值 `{ surfaceId, model }`。`presentationMeta` 持久化 `value.model`,使投影器和执行器不会对规范化结果产生分歧。Native 结果会指明该 Surface,并说明客户端无法渲染面板时,可以通过普通消息绕过它。随后工具调用 `exec.concludeTurn()`,防止 agent 越过所要求的人工检查点继续执行。 -工具定义省略 `isConcurrencySafe`。根据现有工具注册表约定,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `code` 的 profile 不会向模型公布该工具,因为 PTC mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 +工具定义省略 `isConcurrencySafe`。根据现有工具注册表约定,省略该字段会将每次调用归类为独占排序屏障,无需新增 `ToolDefinition` 字段。该工具只会组装到同时挂载 Host 服务和 Web 渲染器的 Web profile 中。版本 1 支持 `native` 和 `both` 工具模式;仅支持 `ptc` 的 profile 不会向模型公布该工具,因为 PTC mode 分发属于嵌套调用,无法把呈现元数据传到外层结果。 浏览器安全的领域包从 `@deepseek-ai/dsh-brand` 以仅类型方式导入 `Branded` 原语,并拥有全部三个 Task Surface ID。根据[规范工具输出约定](../../implemented/architecture/2026-07-20-canonical-tool-output-contract.zh.md),规范值仅存在于本次执行中。因此,回放通过 `output.presentationMeta(args, value)` 将以下带标签的载荷随 `tool/result.meta` 一并持久化: @@ -260,7 +260,7 @@ Web 插件将未提交值保存在一个有界、按会话持久化的 slot stor ## 验收标准 -- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `code` 的模式不会向模型公布该工具。 +- 在 `native` 或 `both` 工具模式下,真实模型可以调用一个稳定的 `show_task_surface` schema;调用结束当前轮次;具备相应能力的 Web 客户端在实时运行和回放后都能渲染同一份规范化模型;仅支持 `ptc` 的模式不会向模型公布该工具。 - 静态 `TaskSurfaceDock` 是唯一的编辑器,即使活动结果位于已加载历史窗口之外也仍可操作;带 key 的 toolview 始终是 transcript 的只读摘要和回放。composer 接管会隐藏仍处于挂载状态的 Dock、保留其草稿,并在接管释放后重新显示同一个所有者。 - 每个 `submissionId` 的提交操作恰好生成一条可见用户消息,通过普通队列接纳开始下一轮,并在保留 `source.kind: 'user'` 的同时维持带品牌类型的确切调用实例关联;关闭操作记录一条日志事件,且不启动轮次。 - 客户端排队行保留已关联的消息来源。`getActive` 可在同一进程的重新连接前后公开 `queued` 或 `claiming`;持久化完成后会关闭投影,显式丢弃则会清除待处理状态并让 Surface 保持打开。队列行消失本身不会改变任何 UI 状态。系统会拒绝编辑和 steering,且移除操作只能在认领前成功。 diff --git a/packages/spill/spill-policy/README.i18n.yaml b/packages/spill/spill-policy/README.i18n.yaml index a269409815..213d02d550 100644 --- a/packages/spill/spill-policy/README.i18n.yaml +++ b/packages/spill/spill-policy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/spill/spill-policy/README.md -README.md: ce7db195fe79623040f38da749ee1d45a46e317a -README.zh.md: f412f32e34c12a8ba3b28d272c932a463f2359d2 +README.md: ee93ef40c1655776ea4ece9f682223a9def185bb +README.zh.md: bd46545f5c16dba50a6c5f6562bcc80d8996b553 diff --git a/packages/spill/spill-policy/README.md b/packages/spill/spill-policy/README.md index ce7db195fe..ee93ef40c1 100644 --- a/packages/spill/spill-policy/README.md +++ b/packages/spill/spill-policy/README.md @@ -84,7 +84,7 @@ The policy is deliberately narrow: it only decides **when** to spill and compose ### The two arms -A `tools/post-execute` waterfall listener (registered with `prepend`, delegating via `next()`) bounds the model-facing result; a `tools/code-dispatch-log` listener bounds the durable log copy of each `run_code` sub-call. Both share one replacement helper so the two projections are byte-identical. The post-execute arm skips `read` to avoid a read → spill → read loop; the dispatch-log arm bounds `read` sub-calls because a log copy is not model context. +A `tools/post-execute` waterfall listener (registered with `prepend`, delegating via `next()`) bounds the model-facing result; a `tools/ptc-dispatch-log` listener bounds the durable log copy of each `run_code` sub-call. Both share one replacement helper so the two projections are byte-identical. The post-execute arm skips `read` to avoid a read → spill → read loop; the dispatch-log arm bounds `read` sub-calls because a log copy is not model context. ### Source map @@ -111,7 +111,7 @@ Read these pages when the package-level contract is not enough. - [dsh-spill-local](../spill-local/README.md) — the local backend that stores the spilled text. - [dsh-output-retention](../../util/output-retention/README.md) — the preview mechanics (`TextRetainer`) the policy composes. - [Tool output spill decision](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md) — the capability boundary and design rationale. -- [Code dispatch-log spill decision](../../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md) — why the durable log copy is bounded too. +- [PTC dispatch-log spill decision](../../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.md) — why the durable log copy is bounded too. ----- diff --git a/packages/spill/spill-policy/README.zh.md b/packages/spill/spill-policy/README.zh.md index f412f32e34..bd46545f5c 100644 --- a/packages/spill/spill-policy/README.zh.md +++ b/packages/spill/spill-policy/README.zh.md @@ -84,7 +84,7 @@ kind: "package-reference" ### 两条分支 -`tools/post-execute` waterfall(瀑布式事件)监听器(以 `prepend` 注册、通过 `next()` 委托)约束面向模型的结果;`tools/code-dispatch-log` 监听器约束每个 `run_code` 子调用的持久日志副本。两者共享同一个替换辅助函数,因此两个投影字节一致。post-execute 分支跳过 `read` 以避免 read → spill → read 循环;dispatch-log 分支约束 `read` 子调用,因为日志副本不是模型上下文。 +`tools/post-execute` waterfall(瀑布式事件)监听器(以 `prepend` 注册、通过 `next()` 委托)约束面向模型的结果;`tools/ptc-dispatch-log` 监听器约束每个 `run_code` 子调用的持久日志副本。两者共享同一个替换辅助函数,因此两个投影字节一致。post-execute 分支跳过 `read` 以避免 read → spill → read 循环;dispatch-log 分支约束 `read` 子调用,因为日志副本不是模型上下文。 ### 源码地图 @@ -111,7 +111,7 @@ kind: "package-reference" - [dsh-spill-local](../spill-local/README.zh.md)——保存 spill 文本的本地后端。 - [dsh-output-retention](../../util/output-retention/README.zh.md)——策略组合的预览机制(`TextRetainer`)。 - [工具输出 spill 决策](../../../.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md)——能力边界与设计依据。 -- [代码 dispatch-log spill 决策](../../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md)——为何持久日志副本同样设界。 +- [PTC dispatch-log spill 决策](../../../.agents/notes/implemented/feature/2026-07-26-ptc-dispatch-log-spill.zh.md)——为何持久日志副本同样设界。 ----- From 6c705be1ce6774a000d061da41d1823b03a3d42c Mon Sep 17 00:00:00 2001 From: imccyu Date: Fri, 28 Aug 2026 00:50:12 +0800 Subject: [PATCH 130/130] release(dsh): 0.1.2-alpha.1 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/api/session-controller/package.json | 2 +- packages/api/settings-controller/package.json | 2 +- packages/api/workspace-controller/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/acp-app/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/sdk-app/package.json | 2 +- packages/bundle/sdk-minimal/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/store/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-approval/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-brand-official/package.json | 2 +- packages/client/ui-chat/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-reference/package.json | 2 +- packages/client/ui-renderer/package.json | 2 +- packages/client/ui-session/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-python/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/authorization/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/examples/agent-spine-demo/package.json | 2 +- packages/experimental/agent-team-profile/package.json | 2 +- packages/experimental/agent-team-web-profile/package.json | 2 +- packages/experimental/agent-team/package.json | 2 +- packages/experimental/client-ui-agent-team/package.json | 2 +- packages/experimental/inspector/package.json | 2 +- packages/experimental/tool-agent-team/package.json | 2 +- packages/experimental/webworker-packer/package.json | 2 +- packages/experimental/webworker-runtime/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/deepseek-llm-api-extensions/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/plugin-package-inventory-deepseek/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-log-deepseek/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence-sqlite/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent-report/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/subprocess/win32-process/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/test-support/session-snapshot/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/crypto/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/util/workspace-path/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/webhook/webhook-github/package.json | 2 +- packages/webhook/webhook/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 250 files changed, 250 insertions(+), 250 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index a07102d212..8f82b8fdf7 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/apps/web/package.json b/apps/web/package.json index eeb6693e6b..26554dc5d2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index fac0df28e8..f5718565aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 1bd6af44c1..038be6f7a9 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 13c7e402ae..d5601ab188 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index aef2bcc642..8a271fe29a 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 191ad25909..4aa30bd9e4 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 4de5f8d490..3f3039aa68 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index bc045f38c7..14586d912e 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 194112be29..550a44efd6 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 1abd03e3e0..d2448330bc 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index d33cae8870..bd03b913cd 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 60c63d4ec4..83c272e666 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 5113761d29..4262b34412 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 82dcc1e007..daef0bab99 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index d8c97032c1..474dc74dd6 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index 6ab4167d8d..5e02cda63e 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 42940a0ac8..f94592396c 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 85a61a1d5b..a018658347 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 07a00ab2de..f19f30e40d 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 3ad65222f5..35f1beed3c 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 355ba7000e..466734cc9b 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 62926fa35b..1e608f4c58 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index f2d04490ba..e6b8eb47e6 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 60a91df206..7ec49fd96f 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 90f61b4893..060cb0f386 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index d5a10b446b..68efaafcd5 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 33180942fe..b40b6d8664 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar and conversation Hero slots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 10382a63bf..992ea08102 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 07c5c18a52..a5a15451f2 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index c86c5f335e..e9e434071b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 15dce11815..a977a5e609 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 2d0003c1e8..90cd0ce667 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 986e38be54..9bb352e7cb 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index a14e25110e..1bd3457b73 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index d87637ed67..6114813aa3 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index a195c70e08..e944070fe6 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index d07ae0d563..ea634dbafa 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index 19df358147..51ca031762 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index d14dcd894d..e368cfb6a9 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 037c6d15bd..3531416e93 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 8c3f87f136..97cd4f4ca4 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 9410edae05..8041b7dbf3 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index f7e9afbde5..70928a652c 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 88729acada..997ac47de6 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index 34087f6979..7ab9428e32 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 441433fadd..3f4de05dd4 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 57fa2abcc6..8c65aacc8f 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 270e353444..1049448e57 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 7edd06ff4e..c0cdbaf6b7 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 94d9e4c6d7..6c5bdc2be3 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 50ccb7ed46..783a743df5 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d90bd9fbb2..d6e679509b 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index d93b9addc9..0e317a33ee 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 5aeeb4189b..75afb63a17 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 4bb37da9e4..ee343d5a89 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 ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 0e38fce093..ca96120343 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 054dc9ddf9..242810c753 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 48137b5d0a..b8059eecfb 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index ac9548938b..bc5273310b 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index b009ef95eb..6992a883c1 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 9083a5bc21..6d663b1056 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 boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-python/package.json b/packages/code-runtime/code-runtime-python/package.json index 0cb5b0413d..9f49a052eb 100644 --- a/packages/code-runtime/code-runtime-python/package.json +++ b/packages/code-runtime/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index d655c326bc..7b4b024c71 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index b107880ba7..fda9c1a984 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 4872714c50..f5a7edf1f3 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 8114091eeb..2ee668890c 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index 7bed09943a..d179c4ca25 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index e995cd3d16..9684d9f3bc 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 419b8d5c5e..d3e7e7fd4d 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 4c2170ac60..c86b7350c6 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 1713c8d341..a84e7449ca 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index e28859eea6..72c5bb1891 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.sessionReferenceResolver)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 4ed058608b..7c7b33771c 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index e513897388..ed2aa82b36 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 6b2b5ef87a..d236420235 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index ca3961c893..7c259f6d4e 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index ed795edc23..51ef164ac2 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 115feb18aa..1328122ea8 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 67b7fc8cc5..b14fa0c9e9 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index f1ba6dd289..76062c71a3 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 6b09b326d7..584aa1b0b3 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index aa567e811f..741d82cc10 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index c88162f140..60d4b55830 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 5d802e76e2..22ad915e46 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 13a5f0f794..35102c4603 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index e8e979c880..c6efe178dd 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index ab88b3e5c5..d2dce5c4c7 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 25d98bfe39..c05d938c21 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 2d19e61ff5..37fb2cff64 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index d18f2ebebb..fcdfa983ee 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index bc0e0c36b9..0b29840755 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Private Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 1aa07e6c1e..ecf2c2af0c 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index dccaf91caa..c21cd7a6c2 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index c13ed177d2..34bc8c9a1c 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index aae5baad20..45ca91eb29 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index a8155676dd..9fa51d3fe3 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index bfcff40db5..39e593b3cf 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 3bc0539e38..6c3e49abed 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 6fce78d714..4f49b95c8d 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 6e8428322e..0c1a2c956a 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index c71fd086c3..f37b0a8dc5 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index ecaf45a615..dac634d5a6 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index ecad54a19b..75359357f9 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 58d86392e5..b267d2da82 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 35af55cc86..98d7d9d907 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index c41b249a59..a362fd32bb 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 1d4f51dfd9..c86055e99b 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8b265eb6bc..fb86a437de 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 7214e5fa46..71c57d2eef 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index a055f01752..d84cd77e06 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index fe0edc727d..9320d9f4d3 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index eb16656526..ec487938fb 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 5082782244..fa54234e83 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 576b60b235..f6cce2ff35 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 9bc0631cd7..3c0e873274 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index bef6cf1251..3424ff6c92 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 7d65ddb10a..f89330eaac 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 958a94e4dd..5255f94388 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 1308ea38db..980a01889d 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 6693a4e6de..ffc7337a0b 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 2936081351..48da08f3c8 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 8215d96874..341fb22e75 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 6fb6652bdc..d6fc2a352e 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 067548dca2..97161a2b70 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index ebfcf857a6..e9db804471 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index e3fc2bb6d9..5715d31f09 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 35019e6c7b..543541487f 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index f5eebc76d1..4a6d6660b3 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index a9e3313204..aff216895b 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 067115a5d2..4bdeb65940 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.userQuestions seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 5ec59a32d6..b414c6f567 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 21b01aeae3..e2313a3a97 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 117f9d9e99..02b00e36e1 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index c658d2d49f..fec602b584 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index fc1fc6877c..07cee3a02b 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index b82d01328d..a7a5c17eda 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 49f42e6539..ebddd5b4f5 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index ce809946be..c8c3963f28 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index e66c909078..e7b6168a65 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 1ec340226e..64c2eb0123 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 4b8eea1427..ce6c4d2673 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 69ac327734..fcae3d1ccd 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 3dca6f9433..641f1b5002 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 249b8249da..2b09bbe2be 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index de95b13daa..5aab5810e9 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 37af73f583..fed592a473 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 07e7d0cf4c..77162a5d81 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index adfae99ea4..8206d061b6 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 8eb81d930b..09370f1cfa 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 9cbd4110e7..50e357335e 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 12c948b7c6..cea6df459d 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index eda4650b47..9abe6e2b26 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index fc3eb85c85..7af347e7f1 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 3bec839e4d..fa2225eb83 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 9cf315d544..09357cd72b 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index b733286d89..1af6d101b6 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index c5bac4b7e7..a513b6ce2f 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index cbaa9ab2eb..0d98ab7873 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 380357093c..f4a124c78b 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, "repository": { "type": "git", diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 01bd2da609..9d08ddbef0 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 1e6e488d0b..54dc7fa408 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 99ae13a209..39c038ca0b 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 563ddd47d1..30bd9ffc72 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 901bfc6180..92ec7fc95b 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 190de373b5..02b57ba93a 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 01563c3738..192f38b572 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 with physical chunk-row packing", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 195bb39ef5..ac73947e94 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 1f570554d8..d6ab39e78f 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 checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index ca0bbba5a1..92a5155562 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index 4627864028..41b8ea424f 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 4d75af4d12..c912bb2398 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index f802989e6d..b89878c1d7 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": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index de26cc91db..4152e64fa6 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index 86c3ebd34d..83edceb269 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 7c33b3fde6..5be3cfc74d 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 57b3a03244..ba1f05587e 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 3d0d2e460a..71229de2af 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 9316515b1b..3809419fb4 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index f26125d911..9887ebad75 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 4464609f97..8d425537f1 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index f76007bd96..65831007e6 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 267a89c4e6..f12d993282 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index bd796263d6..048972a734 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 02d4792c8a..c05a9a2e04 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index c66bef18e3..a3e48705a7 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 321e2822d2..ee5b02347b 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index b722f12a05..dc73e87d6f 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 0b83e9ce95..bb2ab6c851 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 0f01470e6d..507cee3436 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 4cd30a85c9..4821aba5d4 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 04fa4dcd92..ff95cad8d4 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 798c09ca05..e2823175d4 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 86eb53c585..e594a20dce 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index fec25041f6..44824ae2ab 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 4dc57a5b51..ea192a48e0 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 3f4984bce2..d3c6b8b5fa 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index b147083c59..0e1c060e6a 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 2b96120223..863c87caec 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index a4e1135596..7d92719411 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 28dca2155b..4af0fa7d59 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 04c7e67a04..ac396403b8 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 0c4c32d696..a93904abe1 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index e6bde7838f..6a81ae9a56 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index b7383f45bf..b5e1188ed4 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index 70ffbab72d..08e6191ba8 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 3ea456cf4e..0f1f7d124f 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index c89f11eee7..425c7b9b03 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 5dd5d665d6..b669d611bc 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index a0ff7ecede..8c1b9ba435 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 708ca2335b..73a672778d 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 8dbb8acb6a..0db6d18262 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 274ed88295..1a10def591 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 802287e09c..cf4202c432 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 9123778a22..1f74e9398f 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 7faf010edf..aed22656b7 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index c4d4eefe7f..3aeaab8b25 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index cba0828f2b..9616ed925c 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 76d5f08c4a..427a2a6b2d 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index eac77fc152..e49f324231 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 315a3b456b..4a3f977674 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 096aa0d982..067b179cd0 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index a8d8475607..57c4b94d81 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index e710fcfa30..7303383ef9 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 78a18e510f..ec0ffaab52 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 8433b81657..b97a31d84a 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 12f8283931..77404df033 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index b66a41ea7f..a567280cf7 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 481ea9246b..ff14c62789 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index fd383c1360..b19ebbdb9b 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 097bc5cdaf..9abf359b5c 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 45ccf74b9a..79d5f4d619 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index 18db056476..0e3c7667c6 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 43dc4c7657..69a1194411 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": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 491bb5b49c..7333dfe9f2 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 43f8a36507..1e5471055f 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index c1572fe690..348af58fd2 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 751ae9e376..e74c467c98 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 602ce5d239..dc0697c111 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index dc1a46bf0f..1f6ea441c9 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 5ff018d22a..a189645134 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 467f54299f..ae3c485ef0 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index c6c3dd5ff3..03fc83a770 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index a430a96a2e..c85ee5a031 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index c37e5f5356..024b5889ec 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 980ed904a6..d8862b3e35 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.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 455d2ee54c..829e763971 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.workflowEngine", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 0a3713f7de..0a8aa4f7ae 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 17ff2d2959..b8cb09ece8 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.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 6c2fa2782e..c3126d9aa7 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.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" },